feat: extract MediaController, wire into Dispatcher, delete media.php

This commit is contained in:
Pontoporeia
2026-04-17 11:44:08 +02:00
parent b03be51b92
commit 75f808bee4
157 changed files with 1713 additions and 452 deletions

45
app/bootstrap.php Normal file
View File

@@ -0,0 +1,45 @@
<?php
/**
* Simple configuration for website
*/
// Define application root
define('APP_ROOT', __DIR__);
// Storage directory for uploaded files — intentionally outside the webroot
// so no uploaded content is ever directly web-accessible (items #3 & #4).
// Files are served through MediaController which validates paths and MIME types.
define('STORAGE_ROOT', '/var/www/posterg/storage');
// Error reporting
if (php_sapi_name() === 'cli-server') {
// Development mode
error_reporting(E_ALL);
ini_set('display_errors', '1');
} else {
// Production mode
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
}
// Admin password hash is stored in site_settings (DB).
// AdminAuth reads it on demand — no static config file needed.
// Central application helper (boot, auth guard, CSRF, flash, render)
require_once APP_ROOT . '/src/App.php';
// Maintenance mode gate — block public pages; allow /admin/ through.
// The flag file lives in storage/ (outside webroot) to avoid web exposure.
define('MAINTENANCE_FLAG', APP_ROOT . '/storage/maintenance.flag');
if (file_exists(MAINTENANCE_FLAG)) {
// Allow admin panel through (by path prefix) and the maintenance page itself
$requestPath = $_SERVER['REQUEST_URI'] ?? '';
$isAdmin = str_starts_with($requestPath, '/admin');
$isMaintenance = str_contains($requestPath, 'maintenance.php');
if (!$isAdmin && !$isMaintenance) {
require APP_ROOT . '/public/maintenance.php';
exit();
}
}

View File

@@ -0,0 +1,36 @@
# Security headers
<IfModule mod_headers.c>
# Prevent clickjacking
Header always set X-Frame-Options "SAMEORIGIN"
# Prevent MIME type sniffing
Header always set X-Content-Type-Options "nosniff"
# Enable XSS protection
Header always set X-XSS-Protection "1; mode=block"
# Referrer policy
Header always set Referrer-Policy "strict-origin-when-cross-origin"
# Content Security Policy (adjust as needed)
Header always set Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;"
</IfModule>
# Prevent directory listing
Options -Indexes
# Protect sensitive files
<FilesMatch "^\.">
Require all denied
</FilesMatch>
<FilesMatch "(composer\.(json|lock)|error\.log)$">
Require all denied
</FilesMatch>
# PHP security settings (if .htaccess can override)
<IfModule mod_php.c>
php_flag display_errors Off
php_flag log_errors On
php_value error_log error.log
</IfModule>

145
app/public/admin/README.md Normal file
View File

@@ -0,0 +1,145 @@
# Admin Panel Structure
This directory contains the admin panel for managing Post-ERG thesis database.
## Directory Structure
```
public/admin/
├── index.php # List all theses (main page)
├── add.php # Add new thesis form
├── edit.php # Edit existing thesis form
├── import.php # CSV import form
├── thanks.php # Thank you page after submission
├── actions/ # Backend processing scripts (no HTML output)
│ ├── formulaire.php # Process thesis submission from add.php
│ └── publish.php # Toggle publish/unpublish status
├── inc/ # Shared templates
│ ├── head.php # HTML head, CSS, navigation
│ └── footer.php # HTML footer
└── data/ # Upload directory (not in git)
├── theses/ # PDF files
└── covers/ # Cover images
```
## File Types
### User-Facing Templates (Root Directory)
Files that display HTML to users:
- **index.php** - Lists all theses with filters and bulk actions
- **add.php** - Form to add a new thesis
- **edit.php** - Form to edit an existing thesis
- **import.php** - CSV import interface
- **thanks.php** - Success confirmation page
### Backend Scripts (actions/)
Files that process forms and redirect (no HTML output):
- **formulaire.php** - Processes thesis submission from add.php
- **publish.php** - Handles publish/unpublish actions
### Shared Templates (inc/)
Reusable HTML components:
- **head.php** - HTML head, CSS links, navigation menu
- **footer.php** - HTML footer
## Workflow
### Adding a Thesis
1. User visits `add.php` (displays form)
2. User submits form to `actions/formulaire.php` (processes data)
3. On success, redirects to `thanks.php?id=123`
4. On error, redirects back to `add.php` with error message
### Publishing/Unpublishing
1. User clicks publish/unpublish button in `index.php`
2. Form submits to `actions/publish.php` (processes action)
3. Redirects back to `index.php` with success/error message
## Security
- All pages require HTTP Basic Auth (configured in nginx) — primary layer
- All pages require PHP session auth (`AdminAuth::requireLogin()`) — defence-in-depth
- CSRF tokens protect all forms
- File uploads validated and sanitized
- Database queries use prepared statements
- Upload directory outside public/ in production
See `nginx/PHP_AUTH_LAYER.md` for details on the dual-auth architecture.
## Templates
The `inc/` folder contains shared templates:
- `head.php` - Included at the top of each page (DOCTYPE, CSS, nav)
- `footer.php` - Included at the bottom of each page (closing tags)
Usage:
```php
<?php include "inc/head.php" ?>
<!-- Page content here -->
<?php include "inc/footer.php" ?>
```
## URL Structure
- `/admin/` - List theses (index.php)
- `/admin/add.php` - Add new thesis
- `/admin/edit.php?id=123` - Edit thesis #123
- `/admin/import.php` - Import CSV
- `/admin/thanks.php?id=123` - Thank you page
Backend actions (not directly accessed):
- `/admin/actions/formulaire.php` - Form processor
- `/admin/actions/publish.php` - Publish toggle
## Development
### Adding a New Page
1. Create the template in `/admin/yourpage.php`:
```php
<?php
require_once __DIR__ . "/../../config/bootstrap.php";
require_once __DIR__ . '/../../lib/AdminAuth.php';
AdminAuth::requireLogin();
$pageTitle = "Your Page Title";
?>
<?php include "inc/head.php" ?>
<!-- Your content here -->
<?php include "inc/footer.php" ?>
```
2. Add navigation link in `inc/head.php` if needed
### Adding a New Action
1. Create the script in `/admin/actions/youraction.php`:
```php
<?php
require_once __DIR__ . "/../../config/bootstrap.php";
require_once __DIR__ . '/../../lib/AdminAuth.php';
AdminAuth::requireLogin();
// Verify CSRF token
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
$_SESSION['error'] = "Security error";
header('Location: ../index.php');
exit;
}
// Process action...
// Redirect
header('Location: ../yourpage.php');
exit;
```
2. Create form in template that posts to `actions/youraction.php`
## Notes
- Bootstrap path from actions/: `__DIR__ . "/../../config/bootstrap.php"`
- Redirects from actions/: use `../` prefix (e.g., `../index.php`)
- Database class: `require_once __DIR__ . '/../../lib/Database.php'`
- All forms must include CSRF token from `$_SESSION['csrf_token']`

View File

@@ -0,0 +1,201 @@
<?php
require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php';
require_once __DIR__ . '/../../src/ShareLink.php';
App::adminGuard();
$shareLink = ShareLink::make();
$links = $shareLink->listAll();
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$baseUrl = $protocol . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost');
$pageTitle = 'Accès étudiant·e';
$isAdmin = true;
$bodyClass = 'admin-body';
?>
<?php require_once APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<main id="main-content">
<div class="admin-list-toolbar">
<h1>Accès étudiant·e</h1>
<div class="admin-list-toolbar__right">
<button type="button" class="admin-btn admin-btn--sm" id="open-create-dialog">
Créer un lien
</button>
</div>
</div>
<?php if (empty($links)): ?>
<p class="admin-empty">Aucun lien d'accès créé. Cliquez sur « Créer un lien » pour générer un lien partageable.</p>
<?php else: ?>
<table>
<thead>
<tr>
<th scope="col">Lien</th>
<th scope="col">Statut</th>
<th scope="col">Mot de passe</th>
<th scope="col">Utilisations</th>
<th scope="col">Expiration</th>
<th scope="col">Créé le</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($links as $link): ?>
<?php
$isExpired = $link['expires_at'] !== null && strtotime($link['expires_at']) < time();
$isActive = (bool)$link['is_active'] && !$isExpired;
$statusLabel = $isExpired ? 'Expiré' : ($link['is_active'] ? 'Actif' : 'Désactivé');
if ($isExpired) {
$statusClass = 'status-badge status-pending';
} elseif ($link['is_active']) {
$statusClass = 'status-badge status-published';
} else {
$statusClass = 'status-badge';
$statusClass .= ' style="background:var(--error-muted-bg);color:var(--error);"';
}
$fullUrl = $baseUrl . '/partage/' . htmlspecialchars($link['slug']);
$created = date('d/m/Y H:i', strtotime($link['created_at']));
$expires = $link['expires_at'] ? date('d/m/Y', strtotime($link['expires_at'])) : '—';
$hasPassword = !empty($link['password_hash']);
?>
<tr>
<td>
<code style="font-size:var(--step--2);color:var(--text-secondary);"><?= htmlspecialchars($link['slug']) ?></code>
<input type="hidden" id="url-<?= $link['id'] ?>" value="<?= $fullUrl ?>">
</td>
<td>
<?php if ($isExpired): ?>
<span class="status-badge status-pending"><?= $statusLabel ?></span>
<?php elseif ($link['is_active']): ?>
<span class="status-badge status-published"><?= $statusLabel ?></span>
<?php else: ?>
<span style="display:inline-block;padding:var(--space-3xs) var(--space-2xs);border-radius:3px;font-size:var(--step--2);font-weight:500;letter-spacing:0.04em;background:var(--error-muted-bg);color:var(--error);"><?= $statusLabel ?></span>
<?php endif; ?>
</td>
<td><?= $hasPassword ? '🔒 Oui' : 'Non' ?></td>
<td style="text-align:center;"><?= intval($link['usage_count']) ?></td>
<td><?= $expires ?></td>
<td><?= $created ?></td>
<td>
<div class="admin-actions">
<button type="button" class="admin-btn-sm admin-btn-view"
onclick="copyUrl(<?= $link['id'] ?>)" title="Copier l'URL">
Copier
</button>
<form method="post" action="actions/acces-etudiante.php" class="publish-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="toggle">
<input type="hidden" name="id" value="<?= $link['id'] ?>">
<button type="submit"
class="admin-btn-sm <?= $link['is_active'] ? 'admin-btn-unpublish' : 'admin-btn-publish' ?>"
title="<?= $link['is_active'] ? 'Désactiver' : 'Activer' ?>">
<?= $link['is_active'] ? '⏸' : '▶' ?>
</button>
</form>
<button type="button" class="admin-btn-sm admin-btn-edit"
onclick="openPasswordDialog(<?= $link['id'] ?>, <?= $hasPassword ? 'true' : 'false' ?>)"
title="Modifier le mot de passe">
🔑
</button>
<form method="post" action="actions/acces-etudiante.php" class="publish-form"
onsubmit="return confirm('Supprimer ce lien ? Les soumissions via ce lien seront bloquées.')">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="<?= $link['id'] ?>">
<button type="submit" class="admin-btn-sm admin-btn-delete" title="Supprimer">
🗑
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</main>
<!-- ═══════════════════════ CREATE DIALOG ═══════════════════════ -->
<dialog id="create-dialog" class="admin-dialog" aria-labelledby="create-dialog-title">
<div class="admin-dialog__header">
<h2 id="create-dialog-title">Créer un lien d'accès</h2>
<button type="button" class="admin-dialog__close" aria-label="Fermer"
onclick="document.getElementById('create-dialog').close()">&#x2715;</button>
</div>
<form method="post" action="actions/acces-etudiante.php" class="admin-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="create">
<div>
<label for="create-password">Mot de passe (optionnel)</label>
<input type="password" id="create-password" name="password" autocomplete="new-password">
<small>Laissez vide pour un lien sans mot de passe.</small>
</div>
<div>
<label for="create-expires">Expiration (optionnel)</label>
<input type="datetime-local" id="create-expires" name="expires_at">
<small>Laissez vide pour qu'il n'expire jamais.</small>
</div>
<div class="admin-form-footer">
<button type="submit" class="admin-btn">Créer le lien</button>
<button type="button" class="admin-btn-secondary"
onclick="document.getElementById('create-dialog').close()">Annuler</button>
</div>
</form>
</dialog>
<!-- ═══════════════════════ PASSWORD DIALOG ═══════════════════════ -->
<dialog id="password-dialog" class="admin-dialog" aria-labelledby="password-dialog-title">
<div class="admin-dialog__header">
<h2 id="password-dialog-title">Mot de passe</h2>
<button type="button" class="admin-dialog__close" aria-label="Fermer"
onclick="document.getElementById('password-dialog').close()">&#x2715;</button>
</div>
<form method="post" action="actions/acces-etudiante.php" class="admin-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="set_password">
<input type="hidden" name="id" id="password-link-id" value="">
<div>
<label for="password-input">Nouveau mot de passe</label>
<input type="password" id="password-input" name="password" autocomplete="new-password">
<small>Laissez vide pour supprimer le mot de passe.</small>
<p id="password-current-info" style="font-size:var(--step--2);color:var(--text-secondary);margin-top:var(--space-2xs);"></p>
</div>
<div class="admin-form-footer">
<button type="submit" class="admin-btn">Enregistrer</button>
<button type="button" class="admin-btn-secondary"
onclick="document.getElementById('password-dialog').close()">Annuler</button>
</div>
</form>
</dialog>
<script>
document.getElementById('open-create-dialog').addEventListener('click', () => {
document.getElementById('create-dialog').showModal();
});
function copyUrl(id) {
const input = document.getElementById('url-' + id);
navigator.clipboard.writeText(input.value).then(() => {
const btn = event.target.closest('button');
const orig = btn.textContent;
btn.textContent = '✓ Copié';
setTimeout(() => { btn.textContent = orig; }, 1200);
});
}
function openPasswordDialog(id, hasPassword) {
document.getElementById('password-link-id').value = id;
const info = document.getElementById('password-current-info');
info.textContent = hasPassword
? 'Un mot de passe est actuellement configuré. Entrez-en un nouveau ou laissez vide pour le supprimer.'
: 'Aucun mot de passe configuré.';
document.getElementById('password-dialog').showModal();
}
</script>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

View File

@@ -0,0 +1,108 @@
<?php
require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
$pageTitle = "Compte administrateur";
$hasPassword = AdminAuth::hasPassword();
// Flash messages are consumed by the flash-messages partial below.
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
?>
<?php $isAdmin = true; $bodyClass = 'admin-body'; require_once APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<main id="main-content">
<h1>Compte administrateur</h1>
<!-- Status info -->
<dl class="admin-account-status">
<div class="admin-account-status__row">
<dt class="admin-account-status__label">Authentification PHP</dt>
<dd><?php $badgeType = 'ok'; $badgeValue = $hasPassword; $badgeOkLabel = 'Active'; $badgeWarnLabel = 'Non configurée'; include APP_ROOT . '/templates/partials/status-badge.php'; ?></dd>
</div>
<div class="admin-account-status__row">
<dt class="admin-account-status__label">Stockage</dt>
<dd>
<code class="admin-account-status__code">site_settings (DB)</code>
<?php $badgeType = 'ok'; $badgeValue = $hasPassword; $badgeOkLabel = 'Présent'; $badgeWarnLabel = 'Absent'; include APP_ROOT . '/templates/partials/status-badge.php'; ?>
</dd>
</div>
<?php if (!$hasPassword): ?>
<p class="admin-account-status__note">
Aucun mot de passe PHP configuré. Le formulaire ci-dessous stockera
un hash bcrypt dans la base de données.
</p>
<?php endif; ?>
</dl>
<!-- Password change form -->
<h2 class="admin-section-title"><?= $hasPassword ? 'Changer le mot de passe' : 'Définir le mot de passe' ?></h2>
<form method="post" action="/admin/actions/account.php" class="admin-form" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<?php if ($hasPassword): ?>
<div>
<label for="current_password">Mot de passe actuel</label>
<div>
<input type="password" id="current_password"
name="current_password" required autocomplete="current-password">
</div>
</div>
<?php endif; ?>
<div>
<label for="new_password">Nouveau mot de passe</label>
<div>
<input type="password" id="new_password"
name="new_password" required autocomplete="new-password"
minlength="12">
<small>Minimum 12 caractères.</small>
</div>
</div>
<div>
<label for="confirm_password">Confirmer le mot de passe</label>
<div>
<input type="password" id="confirm_password"
name="confirm_password" required autocomplete="new-password">
</div>
</div>
<div class="admin-form-footer">
<button type="submit" class="admin-btn">
<?= $hasPassword ? 'Mettre à jour le mot de passe' : 'Définir le mot de passe' ?>
</button>
</div>
</form>
<?php if ($hasPassword): ?>
<!-- Danger zone: remove password -->
<h2 class="admin-section-title admin-section-title--danger">Zone de danger</h2>
<div class="admin-danger-zone">
<p class="admin-danger-zone__description">
<strong>Supprimer la configuration du mot de passe PHP</strong><br>
<small>
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.
</small>
</p>
<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.')">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="remove_credentials">
<input type="hidden" name="current_password_remove" id="current_password_remove" value="">
<button type="submit" class="admin-btn admin-btn--danger">Supprimer</button>
</form>
</div>
<?php endif; ?>
</main>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

View File

@@ -0,0 +1,69 @@
<?php
/**
* Student-access link actions (create, toggle, set_password, delete).
*/
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
require_once __DIR__ . '/../../../src/ShareLink.php';
App::adminGuard();
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|| !isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
http_response_code(403);
exit('CSRF token invalide.');
}
$action = $_POST['action'] ?? '';
$id = isset($_POST['id']) ? intval($_POST['id']) : 0;
$shareLink = ShareLink::make();
switch ($action) {
case 'create':
$password = !empty($_POST['password']) ? trim($_POST['password']) : null;
$expiresRaw = !empty($_POST['expires_at']) ? trim($_POST['expires_at']) : null;
$expiresAt = null;
if ($expiresRaw) {
// datetime-local gives "YYYY-MM-DDTHH:MM"
$expiresAt = date('Y-m-d H:i:s', strtotime($expiresRaw));
if ($expiresAt <= date('Y-m-d H:i:s')) {
App::redirect('/admin/acces-etudiante.php', error: "La date d'expiration doit être dans le futur.");
}
}
$shareLink->create(1, $password, $expiresAt);
App::redirect('/admin/acces-etudiante.php', success: 'Lien d\'accès créé.');
break;
case 'toggle':
if ($id > 0) {
$shareLink->toggleActive($id);
App::redirect('/admin/acces-etudiante.php', success: 'Statut du lien modifié.');
} else {
App::redirect('/admin/acces-etudiante.php', error: 'Lien introuvable.');
}
break;
case 'set_password':
if ($id > 0) {
$password = isset($_POST['password']) && $_POST['password'] !== '' ? trim($_POST['password']) : null;
$shareLink->setPassword($id, $password);
App::redirect('/admin/acces-etudiante.php', success: 'Mot de passe mis à jour.');
} else {
App::redirect('/admin/acces-etudiante.php', error: 'Lien introuvable.');
}
break;
case 'delete':
if ($id > 0) {
$shareLink->delete($id);
App::redirect('/admin/acces-etudiante.php', success: 'Lien supprimé.');
} else {
App::redirect('/admin/acces-etudiante.php', error: 'Lien introuvable.');
}
break;
default:
App::redirect('/admin/acces-etudiante.php', error: 'Action inconnue.');
break;
}

View File

@@ -0,0 +1,94 @@
<?php
/**
* Admin account action — update or remove admin password.
*
* Actions:
* POST (default) — set/change the PHP admin password
* POST action=remove_credentials — remove the password from DB
*/
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
require_once __DIR__ . '/../../../src/Database.php';
AdminAuth::requireLogin();
// ── CSRF ──────────────────────────────────────────────────────────────────────
if (empty($_SESSION['csrf_token']) || ($_POST['csrf_token'] ?? '') !== $_SESSION['csrf_token']) {
http_response_code(403);
die('Invalid CSRF token.');
}
$hasPassword = AdminAuth::hasPassword();
$action = $_POST['action'] ?? 'change_password';
// ── Remove credential ────────────────────────────────────────────────────────
if ($action === 'remove_credentials') {
$backUrl = $_POST['redirect'] ?? '/admin/parametres.php';
if (!preg_match('#^/admin/#', $backUrl)) { $backUrl = '/admin/parametres.php'; }
if (!$hasPassword) {
App::flash('error', 'Aucun mot de passe à supprimer.');
header('Location: ' . $backUrl);
exit;
}
AdminAuth::removePasswordHash();
// Destroy session so the user is forced to re-authenticate.
AdminAuth::logout();
header('Location: /admin/login.php');
exit;
}
// ── Change / set password ─────────────────────────────────────────────────────
// 1. If a password is already set, verify the current one.
$backUrl = $_POST['redirect'] ?? '/admin/parametres.php';
if (!preg_match('#^/admin/#', $backUrl)) { $backUrl = '/admin/parametres.php'; }
if ($hasPassword) {
$currentPassword = $_POST['current_password'] ?? '';
if (!AdminAuth::login($currentPassword)) {
App::flash('error', 'Mot de passe actuel incorrect.');
header('Location: ' . $backUrl);
exit;
}
}
// 2. Validate new password.
$newPassword = $_POST['new_password'] ?? '';
$confirmPassword = $_POST['confirm_password'] ?? '';
if (strlen($newPassword) < 12) {
App::flash('error', 'Le nouveau mot de passe doit contenir au moins 12 caractères.');
header('Location: ' . $backUrl);
exit;
}
if ($newPassword !== $confirmPassword) {
App::flash('error', 'Les mots de passe ne correspondent pas.');
header('Location: ' . $backUrl);
exit;
}
// 3. Generate bcrypt hash (cost 12).
$hash = password_hash($newPassword, PASSWORD_BCRYPT, ['cost' => 12]);
if ($hash === false) {
App::flash('error', 'Erreur lors du hachage du mot de passe.');
header('Location: ' . $backUrl);
exit;
}
// 4. Store hash in DB.
AdminAuth::setPasswordHash($hash);
// 5. Regenerate session (password changed — invalidate old sessions).
session_regenerate_id(true);
$_SESSION['admin_authenticated'] = true;
App::flash('success', $hasPassword
? 'Mot de passe mis à jour avec succès.'
: 'Mot de passe défini avec succès. L\'authentification PHP est maintenant active.');
header('Location: ' . $backUrl);
exit;

View File

@@ -0,0 +1,76 @@
<?php
/**
* Save handler for apropos contents (contacts, credits).
* Structure: groups[] with label/role, each having entries[] of {text, url, email}.
*/
require_once __DIR__ . "/../../../bootstrap.php";
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
// CSRF check
if (!isset($_POST['csrf_token']) || !isset($_SESSION['csrf_token']) ||
!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
die("Erreur de sécurité : token invalide.");
}
$allowedKeys = ['contacts', 'credits'];
$aproposKey = $_POST['apropos_key'] ?? '';
if (!in_array($aproposKey, $allowedKeys)) {
die("Clé invalide.");
}
require_once __DIR__ . '/../../../src/Database.php';
try {
$db = new Database();
$groups = $_POST['groups'] ?? [];
$cleaned = [];
foreach ($groups as $group) {
if ($aproposKey === 'credits') {
$label = trim($group['label'] ?? '');
if ($label === '') continue;
$entries = [];
foreach ($group['entries'] ?? [] as $entry) {
$text = trim($entry['text'] ?? '');
if ($text === '') continue;
$e = ['text' => $text];
$url = trim($entry['url'] ?? '');
if ($url !== '') $e['url'] = $url;
$entries[] = $e;
}
if (empty($entries)) continue;
$cleaned[] = ['label' => $label, 'entries' => $entries];
} else { // contacts
$role = trim($group['role'] ?? '');
if ($role === '') continue;
$entries = [];
foreach ($group['entries'] ?? [] as $entry) {
$text = trim($entry['text'] ?? '');
if ($text === '') continue;
$e = [
'text' => $text,
'email' => trim($entry['email'] ?? ''),
];
$url = trim($entry['url'] ?? '');
if ($url !== '') $e['url'] = $url;
$entries[] = $e;
}
if (empty($entries)) continue;
$cleaned[] = ['role' => $role, 'entries' => $entries];
}
}
if (empty($cleaned)) {
die("Au moins un groupe avec des entrées est requis.");
}
$db->saveAproposContent($aproposKey, $cleaned);
App::flash('success', "Contenu « $aproposKey » mis à jour avec succès.");
} catch (Exception $e) {
error_log("Apropos save error: " . $e->getMessage());
die("Erreur lors de la sauvegarde : " . htmlspecialchars($e->getMessage()));
}
header('Location: /admin/contenus.php');
exit;

View File

@@ -0,0 +1,60 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
require_once __DIR__ . '/../../../src/Database.php';
// CSRF validation
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
App::flash('error', 'Erreur de sécurité : token invalide.');
header('Location: ../index.php');
exit;
}
$isBulk = !empty($_POST['bulk']);
$isDeleteAll = !empty($_POST['delete_all']);
try {
$db = new Database();
if ($isDeleteAll) {
$count = $db->deleteAllTheses();
App::flash('success', "$count TFE(s) supprimé(s) avec succès.");
} elseif ($isBulk) {
$ids = array_filter(array_map('intval', $_POST['selected_theses'] ?? []), fn($id) => $id > 0);
if (empty($ids)) {
App::flash('error', 'Aucun TFE sélectionné.');
header('Location: ../index.php');
exit;
}
$db->bulkDeleteTheses($ids);
$count = count($ids);
App::flash('success', "$count TFE(s) supprimé(s) avec succès.");
} else {
$thesisId = filter_var($_POST['thesis_id'] ?? '', FILTER_VALIDATE_INT);
if (!$thesisId || $thesisId <= 0) {
App::flash('error', 'ID invalide.');
header('Location: ../index.php');
exit;
}
$db->deleteThesis($thesisId);
App::flash('success', 'TFE supprimé avec succès.');
}
} catch (Exception $e) {
error_log('delete.php error: ' . $e->getMessage());
App::flash('error', 'Erreur lors de la suppression : ' . $e->getMessage());
}
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
header('Location: ../index.php');
exit;

View File

@@ -0,0 +1,53 @@
<?php
// Bootstrap application
require_once __DIR__ . "/../../../bootstrap.php";
require_once __DIR__ . '/../../../src/AdminAuth.php';
// PHP-level auth guard (defence-in-depth behind nginx Basic Auth)
AdminAuth::requireLogin();
// Only handle POST requests
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ../index.php');
exit();
}
// Verify CSRF token
if (!isset($_POST['csrf_token']) || !isset($_SESSION['csrf_token']) ||
!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
error_log("CSRF token validation failed in edit action");
die("Erreur de sécurité : token invalide. Veuillez recharger le formulaire.");
}
$thesisId = isset($_POST['thesis_id']) ? intval($_POST['thesis_id']) : 0;
if ($thesisId <= 0) {
die("ID de TFE invalide.");
}
require_once APP_ROOT . '/src/Controllers/ThesisEditController.php';
try {
$ctrl = ThesisEditController::create();
$ctrl->save($thesisId, $_POST, $_FILES);
// Regenerate CSRF token after successful save
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
App::flash('success', "TFE mis à jour avec succès!");
header('Location: ../edit.php?id=' . $thesisId);
exit();
} catch (Exception $e) {
error_log("Edit action error: " . $e->getMessage());
App::flash('error', $e->getMessage());
// WCAG 3.3.1 — map error message to field name for autofocus on re-render.
$autofocusField = ThesisEditController::autofocusFieldForError($e->getMessage());
if ($autofocusField !== null) {
App::flashAutofocus($autofocusField);
}
header('Location: ../edit.php?id=' . $thesisId);
exit();
}

View File

@@ -0,0 +1,36 @@
<?php
/**
* Export TFE listings as CSV.
*
* Thin dispatcher — delegates all data assembly to ExportController,
* then streams the response.
*/
require_once __DIR__ . "/../../../bootstrap.php";
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
require_once APP_ROOT . '/src/Controllers/ExportController.php';
$controller = ExportController::create();
$filename = 'posterg-export-' . date('Y-m-d') . '.csv';
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Cache-Control: no-cache, must-revalidate');
// UTF-8 BOM for Excel compatibility
echo "\xEF\xBB\xBF";
$out = fopen('php://output', 'w');
// Column headers
fputcsv($out, ExportController::CSV_HEADERS, ',', '"', '');
// Data rows
$rows = $controller->exportAllTheses();
foreach ($rows as $csvLine) {
fputcsv($out, $csvLine, ',', '"', '');
}
fclose($out);
exit;

View File

@@ -0,0 +1,29 @@
<?php
/**
* Export the whole SQLite database as a file download.
*
* Thin dispatcher — delegates to ExportController.
*/
require_once __DIR__ . "/../../../bootstrap.php";
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
require_once APP_ROOT . '/src/Controllers/ExportController.php';
$controller = ExportController::create();
$dbPath = $controller->getDatabasePath();
if (!file_exists($dbPath)) {
http_response_code(500);
exit('Base de données introuvable.');
}
$filename = 'posterg-db-' . date('Y-m-d') . '.sqlite';
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . filesize($dbPath));
header('Cache-Control: no-cache, must-revalidate');
readfile($dbPath);
exit;

View File

@@ -0,0 +1,47 @@
<?php
// Bootstrap application
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
ini_set('display_errors', 0);
ini_set('log_errors', 1);
ini_set('error_log', 'error.log');
AdminAuth::requireLogin();
// Verify CSRF token
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
error_log('CSRF token validation failed in formulaire.php');
die('Erreur de sécurité : token invalide. Veuillez recharger le formulaire.');
}
error_log('FILES array: ' . print_r($_FILES, true));
require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php';
try {
$ctrl = ThesisCreateController::make();
$thesisId = $ctrl->submit($_POST, $_FILES);
unset($_SESSION['csrf_token']);
header('Location: ' . $redirect);
exit();
} catch (Exception $e) {
error_log('ThesisCreateController error: ' . $e->getMessage());
App::flash('error', $e->getMessage());
$_SESSION['form_data'] = $_POST;
$redirect = '../add.php';
$autofocusField = ThesisCreateController::autofocusFieldForError($e->getMessage());
if ($autofocusField !== null) {
App::flashAutofocus($autofocusField);
}
header('Location: ' . $redirect);
exit();
}

View File

@@ -0,0 +1,33 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|| !isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
http_response_code(403);
die("Accès refusé.");
}
$action = $_POST['action'] ?? '';
if ($action === 'enable_maintenance') {
file_put_contents(MAINTENANCE_FLAG, date('c'));
App::flash('success', "Mode maintenance activé.");
} elseif ($action === 'disable_maintenance') {
if (file_exists(MAINTENANCE_FLAG)) {
unlink(MAINTENANCE_FLAG);
}
App::flash('success', "Mode maintenance désactivé.");
} else {
App::flash('error', "Action inconnue.");
}
$redirect = isset($_POST['redirect']) ? $_POST['redirect'] : '/admin/';
// Allow only internal admin redirects for safety
if (!preg_match('#^/admin/#', $redirect)) {
$redirect = '/admin/';
}
header('Location: ' . $redirect);
exit();

View File

@@ -0,0 +1,65 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
require_once __DIR__ . '/../../../src/Database.php';
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
App::flash('error', 'Erreur de sécurité : token invalide.');
header('Location: ../index.php');
exit;
}
$action = $_POST['action'] ?? '';
$isBulk = !empty($_POST['bulk']);
if (!in_array($action, ['publish', 'unpublish'], true)) {
App::flash('error', 'Action invalide.');
header('Location: ../index.php');
exit;
}
$published = ($action === 'publish');
try {
$db = new Database();
if ($isBulk) {
$ids = array_filter(array_map('intval', $_POST['selected_theses'] ?? []), fn($id) => $id > 0);
if (empty($ids)) {
App::flash('error', 'Aucun TFE sélectionné.');
header('Location: ../index.php');
exit;
}
$db->bulkSetPublished($ids, $published);
$count = count($ids);
App::flash('success', $published
? "$count TFE(s) publié(s) avec succès."
: "$count TFE(s) retiré(s) de la publication.");
} else {
$thesisId = filter_var($_POST['thesis_id'] ?? '', FILTER_VALIDATE_INT);
if (!$thesisId || $thesisId <= 0) {
App::flash('error', 'ID invalide.');
header('Location: ../index.php');
exit;
}
$db->setPublished($thesisId, $published);
App::flash('success', $published ? 'TFE publié avec succès.' : 'TFE retiré de la publication.');
}
} catch (Exception $e) {
error_log('publish.php error: ' . $e->getMessage());
App::flash('error', 'Erreur lors de la modification : ' . $e->getMessage());
}
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
header('Location: ../index.php');
exit;

View File

@@ -0,0 +1,49 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
App::flash('error', "Erreur de sécurité : token invalide.");
header('Location: /admin/parametres.php');
exit;
}
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/SmtpRelay.php';
$db = new Database();
$section = $_POST['section'] ?? '';
if ($section === 'formulaire') {
// Save access-type toggle settings
$allowed = ['access_type_libre_enabled', 'access_type_interne_enabled', 'access_type_interdit_enabled'];
foreach ($allowed as $key) {
$value = isset($_POST[$key]) ? '1' : '0';
$db->setSetting($key, $value);
}
App::flash('success', "Paramètres du formulaire mis à jour.");
} elseif ($section === 'smtp') {
$smtpData = [
'host' => $_POST['smtp_host'] ?? '',
'port' => $_POST['smtp_port'] ?? 587,
'encryption' => $_POST['smtp_encryption'] ?? 'tls',
'username' => $_POST['smtp_username'] ?? '',
'from_email' => $_POST['smtp_from_email'] ?? '',
'from_name' => $_POST['smtp_from_name'] ?? 'Post-ERG',
];
// Only update password when user actually typed something.
$pwd = $_POST['smtp_password'] ?? '';
if ($pwd !== '') {
$smtpData['password'] = $pwd;
}
SmtpRelay::updateSettings($db, $smtpData);
App::flash('success', "Paramètres SMTP mis à jour.");
} else {
App::flash('error', "Section inconnue.");
}
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
header('Location: /admin/parametres.php');
exit;

View File

@@ -0,0 +1,50 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|| !isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
http_response_code(403);
die("Accès refusé.");
}
require_once __DIR__ . '/../../../src/Database.php';
try {
$db = new Database();
$action = $_POST['action'] ?? '';
switch ($action) {
case 'rename':
$id = filter_var($_POST['tag_id'] ?? '', FILTER_VALIDATE_INT);
$newName = trim($_POST['new_name'] ?? '');
if (!$id || $newName === '') throw new Exception("Paramètres invalides.");
$db->renameTag($id, $newName);
break;
case 'merge':
$sourceId = filter_var($_POST['source_id'] ?? '', FILTER_VALIDATE_INT);
$targetId = filter_var($_POST['target_id'] ?? '', FILTER_VALIDATE_INT);
if (!$sourceId || !$targetId) throw new Exception("Paramètres invalides.");
$db->mergeTag($sourceId, $targetId);
break;
case 'delete':
$id = filter_var($_POST['tag_id'] ?? '', FILTER_VALIDATE_INT);
if (!$id) throw new Exception("ID invalide.");
$db->deleteTag($id);
break;
default:
throw new Exception("Action inconnue.");
}
App::flash('success', "Opération effectuée.");
} catch (Exception $e) {
App::flash('error', $e->getMessage());
}
header('Location: /admin/tags.php');
exit();

View File

@@ -0,0 +1,55 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
App::flash('error', "Erreur de sécurité : token invalide.");
header('Location: /admin/');
exit;
}
require_once __DIR__ . '/../../../src/Database.php';
$action = $_POST['action'] ?? ''; // 'set_visibility'
$accessTypeId = filter_var($_POST['access_type_id'] ?? '', FILTER_VALIDATE_INT) ?: null;
$isBulk = !empty($_POST['bulk']);
$validAccess = [null, 1, 2, 3];
if (!in_array($accessTypeId, $validAccess, true)) {
App::flash('error', "Valeur de visibilité invalide.");
header('Location: /admin/');
exit;
}
try {
$db = new Database();
if ($isBulk) {
$ids = array_filter(array_map('intval', $_POST['selected_theses'] ?? []), fn($id) => $id > 0);
if (empty($ids)) {
App::flash('error', "Aucun TFE sélectionné.");
header('Location: /admin/');
exit;
}
$db->bulkSetVisibility($ids, $accessTypeId);
App::flash('success', count($ids) . " TFE(s) mis à jour.");
} else {
$thesisId = filter_var($_POST['thesis_id'] ?? '', FILTER_VALIDATE_INT);
if (!$thesisId) {
App::flash('error', "ID invalide.");
header('Location: /admin/');
exit;
}
$db->setVisibility($thesisId, $accessTypeId);
App::flash('success', "Visibilité mise à jour.");
}
} catch (Exception $e) {
error_log("visibility.php error: " . $e->getMessage());
App::flash('error', "Erreur : " . $e->getMessage());
}
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
header('Location: /admin/');
exit;

160
app/public/admin/add.php Normal file
View File

@@ -0,0 +1,160 @@
<?php
require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
if (empty($_SESSION["csrf_token"])) {
$_SESSION["csrf_token"] = bin2hex(random_bytes(32));
}
$pageTitle = "Ajouter un TFE";
require_once __DIR__ . '/../../src/Controllers/ThesisCreateController.php';
try {
$ctrl = ThesisCreateController::make();
extract($ctrl->loadFormData());
} catch (Exception $e) {
error_log('Failed to load form data: ' . $e->getMessage());
die('Erreur lors du chargement du formulaire.');
}
$formData = $_SESSION['form_data'] ?? [];
unset($_SESSION['form_data']);
$autofocusField = App::consumeAutofocus();
/**
* Merge autofocus into the $attrs array for a given field.
*/
function withAutofocus(string $fieldName, array $attrs = []): array {
global $autofocusField;
if ($autofocusField === $fieldName) {
$attrs['autofocus'] = true;
}
return $attrs;
}
function old($key, $default = "") {
global $formData;
return isset($formData[$key]) ? htmlspecialchars($formData[$key]) : $default;
}
function wasSelected($key, $value) {
global $formData;
if (!isset($formData[$key])) return false;
if (is_array($formData[$key])) return in_array($value, $formData[$key]);
return $formData[$key] == $value;
}
?>
<?php
$isAdmin = true;
$bodyClass = 'admin-body';
require_once APP_ROOT . '/templates/head.php';
include APP_ROOT . '/templates/header.php';
?>
<main id="main-content">
<div class="thesis-add-header">
<h1>Ajouter un TFE</h1>
</div>
<form action="actions/formulaire.php" method="post" enctype="multipart/form-data" class="admin-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION["csrf_token"]) ?>">
<!-- ═══════════════════ Informations du TFE ═══════════════════ -->
<fieldset>
<legend>Informations du TFE</legend>
<?php $name = 'titre'; $label = 'Titre :'; $value = old('titre'); $required = true; $attrs = withAutofocus('titre'); include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'subtitle'; $label = 'Sous-titre (si applicable) :'; $value = old('subtitle'); $required = false; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'auteurice'; $label = 'Auteur·ice(s) :'; $value = old('auteurice'); $required = true; $attrs = withAutofocus('auteurice', ['autocomplete' => 'name']); include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'mail'; $label = 'Contact(s) (optionnel) [mail/site/insta/etc.] :'; $value = old('mail'); $attrs = ['autocomplete' => 'email']; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<div class="admin-form-group">
<label class="admin-checkbox-label">
<input type="checkbox" name="contact_public" value="1"
<?= isset($formData['contact_public']) ? 'checked' : '' ?>>
Je veux que mon contact soit accessible à toustes depuis la plateforme xamxam
</label>
<small>Si cette case est cochée, votre contact apparaîtra sur la page publique de votre TFE.</small>
</div>
<div>
<label for="synopsis">Synopsis :</label>
<textarea id="synopsis" name="synopsis"
rows="7" required
<?= $autofocusField === 'synopsis' ? 'autofocus' : '' ?>><?= old('synopsis') ?></textarea>
</div>
</fieldset>
<!-- ═══════════════════ Composition du jury ═══════════════════ -->
<?php require APP_ROOT . '/templates/partials/form/jury-fieldset.php'; ?>
<!-- ═══════════════════ Cadre académique ═══════════════════ -->
<fieldset>
<legend>Cadre académique</legend>
<?php
$name = 'année'; $label = 'Année :'; $value = old('année'); $required = true;
$type = 'number';
$placeholder = date('Y');
$attrs = withAutofocus('année', ['min' => 2000, 'max' => date('Y') + 1]);
include APP_ROOT . '/templates/partials/form/text-field.php';
?>
<?php $name = 'orientation'; $label = 'Orientation :'; $options = $orientations; $selected = $formData['orientation'] ?? ''; $required = true; $placeholder = ''; $attrs = withAutofocus('orientation'); include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'ap'; $label = 'Atelier pluridisciplinaire :'; $options = $apPrograms; $selected = $formData['ap'] ?? ''; $required = true; $placeholder = ''; $attrs = withAutofocus('ap'); include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'finality'; $label = 'Finalité du master :'; $options = $finalityTypes; $selected = $formData['finality'] ?? ''; $required = true; $placeholder = ''; $attrs = withAutofocus('finality'); include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'languages'; $label = 'Langue(s) :'; $options = $languages; $checked = $formData['languages'] ?? []; include APP_ROOT . '/templates/partials/form/checkbox-list.php'; ?>
<?php $name = 'formats'; $label = 'Format(s) :'; $options = $formatTypes; $checked = $formData['formats'] ?? []; include APP_ROOT . '/templates/partials/form/checkbox-list.php'; ?>
<?php $name = 'tag'; $label = 'Mots-clés :'; $value = old('tag'); $placeholder = 'sociologie, anthropologie, ...'; $hint = 'Séparez par des virgules. Max 10 mots-clés.'; $attrs = withAutofocus('tag'); include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
</fieldset>
<!-- ═══════════════════ Fichiers ═══════════════════ -->
<fieldset>
<legend>Fichiers</legend>
<?php $name = 'couverture'; $label = 'Image de couverture :'; $accept = 'image/jpeg,image/png'; $hint = 'JPG, PNG. Taille max : 10 MB.'; include APP_ROOT . '/templates/partials/form/file-field.php'; ?>
<?php $name = 'banner'; $label = 'Image bannière (accueil) :'; $accept = 'image/jpeg,image/png,image/webp'; $hint = 'JPG, PNG ou WEBP. Format paysage recommandé (4:1). Max 5 MB.'; include APP_ROOT . '/templates/partials/form/file-field.php'; ?>
<?php $name = 'files'; $label = 'Fichiers du TFE :'; $accept = '.pdf,.jpg,.jpeg,.png,.mp4,.zip,.vtt'; $hint = 'PDF, JPG, PNG, MP4, ZIP. Max 50 MB par fichier. Pour les vidéos, un fichier .vtt de sous-titres peut être joint (il sera associé automatiquement à la vidéo correspondante).'; $multiple = true; include APP_ROOT . '/templates/partials/form/file-field.php'; ?>
</fieldset>
<!-- ═══════════════════ Métadonnées complémentaires ═══════════════════ -->
<fieldset>
<legend>Métadonnées complémentaires</legend>
<?php $name = 'license_id'; $label = 'Licence :'; $options = $licenseTypes; $selected = $formData['license_id'] ?? ''; $placeholder = '— Inconnue —'; include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'duration_info'; $label = 'Durée / Taille :'; $value = old('duration_info'); $placeholder = 'Ex : 84 pages'; $hint = 'Durée (minutes) ou nombre de pages.'; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'lien'; $label = 'Lien (site / ressource) :'; $value = old('lien'); $type = 'url'; $placeholder = 'https://...'; $attrs = withAutofocus('lien'); include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php
$accessOptions = array_map(function($at) {
return ['id' => $at['id'], 'name' => $at['name']];
}, $enabledAccessTypes);
$defaultAccessType = 2;
$selectedAccessType = isset($formData['access_type_id'])
? (int)$formData['access_type_id']
: $defaultAccessType;
$name = 'access_type_id';
$label = 'Visibilité / Accès :';
$options = $accessOptions;
$selected = $selectedAccessType;
$placeholder = null;
$required = true;
$attrs = [];
include APP_ROOT . '/templates/partials/form/select-field.php';
?>
</fieldset>
<div class="form-footer">
<button type="submit" name="go">Soumettre</button>
</div>
</form>
</main>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

View File

@@ -0,0 +1,232 @@
<?php
require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
require_once __DIR__ . '/../../src/Database.php';
if (empty($_SESSION["csrf_token"])) {
$_SESSION["csrf_token"] = bin2hex(random_bytes(32));
}
$allowedPageSlugs = ["about", "licenses", "charte"];
$allowedApropos = ["contacts", "credits"];
$pageSlug = $_GET["slug"] ?? "";
$aproposKey = $_GET["apropos"] ?? "";
// Exactly one target must be specified
if ($pageSlug && !in_array($pageSlug, $allowedPageSlugs)) {
$pageSlug = "";
}
if ($aproposKey && !in_array($aproposKey, $allowedApropos)) {
$aproposKey = "";
}
if (!$pageSlug && !$aproposKey) {
header("Location: /admin/contenus.php");
exit();
}
try {
$db = new Database();
if ($pageSlug) {
$page = $db->getPage($pageSlug);
if (!$page) {
die("Page introuvable.");
}
$editTitle = $page["title"];
$editType = "page";
} else {
$editType = "apropos";
$value = $db->getAproposContent($aproposKey);
$editTitle = match($aproposKey) {
'contacts' => 'Contacts',
'credits' => 'Crédits',
};
}
} catch (Exception $e) {
die("Erreur: " . htmlspecialchars($e->getMessage()));
}
$pageTitle = "Éditer : " . $editTitle;
$extraJs = ["/assets/js/overtype.min.js"];
$extraJsInline = <<<'JS'
var OT = window.OverType.default || window.OverType;
var hidden = document.getElementById('content');
var editor = new OT(document.getElementById('editor'), {
value: hidden.value,
minHeight: '400px',
spellcheck: false,
onChange: function(value) { hidden.value = value; }
});
JS;
$aproposEditorJs = null;
if ($editType === 'apropos' && in_array($aproposKey, ['contacts', 'credits'])) {
// Rich textarea for JSON arrays rendered as structured form
$aproposEditorJs = <<<'JS'
// Auto-format JSON in the hidden field for display purposes
JS;
}
$initialContent = '';
if ($editType === 'page') {
$initialContent = $page["content"] ?? "";
} else {
// For apropos, show structured form
}
?>
<?php
$isAdmin = true;
$bodyClass = "admin-body";
require_once APP_ROOT . "/templates/head.php";
?>
<?php include APP_ROOT . "/templates/header.php"; ?>
<main id="main-content">
<h1>Éditer : <?= htmlspecialchars($editTitle) ?></h1>
<?php if ($editType === 'page'): ?>
<form action="/admin/actions/page.php" method="post" class="admin-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION["csrf_token"]) ?>">
<input type="hidden" name="slug" value="<?= htmlspecialchars($pageSlug) ?>">
<label for="editor">Contenu (Markdown) :</label>
<input type="hidden" id="content" name="content"
value="<?= htmlspecialchars($initialContent) ?>">
<div id="editor"></div>
<div class="admin-form-footer">
<button type="submit" class="admin-btn">Enregistrer</button>
<a href="/admin/contenus.php" class="admin-btn-secondary admin-cancel-link">Annuler</a>
</div>
</form>
<?php else: ?>
<?php
$groups = is_array($value) ? $value : [];
?>
<form action="/admin/actions/apropos.php" method="post" class="admin-form" id="apropos-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION["csrf_token"]) ?>">
<input type="hidden" name="apropos_key" value="<?= htmlspecialchars($aproposKey) ?>">
<?php foreach ($groups as $gi => $group): ?>
<fieldset class="apropos-group">
<legend><?= htmlspecialchars($aproposKey === 'contacts' ? 'Contact' : 'Crédit') ?> <?= $gi + 1 ?></legend>
<?php if ($aproposKey === 'contacts'): ?>
<label for="group_<?= $gi ?>_role">Rôle :</label>
<input type="text" id="group_<?= $gi ?>_role"
name="groups[<?= $gi ?>][role]"
value="<?= htmlspecialchars($group['role'] ?? '') ?>">
<?php else: ?>
<label for="group_<?= $gi ?>_label">Label :</label>
<input type="text" id="group_<?= $gi ?>_label"
name="groups[<?= $gi ?>][label]"
value="<?= htmlspecialchars($group['label'] ?? '') ?>">
<?php endif; ?>
<?php $entries = is_array($group['entries'] ?? null) ? $group['entries'] : []; ?>
<?php foreach ($entries as $ei => $entry): ?>
<div class="apropos-entry">
<label for="entry_<?= $gi ?>_<?= $ei ?>_text"><?= $aproposKey === 'contacts' ? 'Nom' : 'Texte' ?> :</label>
<input type="text" id="entry_<?= $gi ?>_<?= $ei ?>_text"
name="groups[<?= $gi ?>][entries][<?= $ei ?>][text]"
value="<?= htmlspecialchars($entry['text'] ?? '') ?>">
<?php if ($aproposKey === 'contacts'): ?>
<label for="entry_<?= $gi ?>_<?= $ei ?>_email">Email :</label>
<input type="email" id="entry_<?= $gi ?>_<?= $ei ?>_email"
name="groups[<?= $gi ?>][entries][<?= $ei ?>][email]"
value="<?= htmlspecialchars($entry['email'] ?? '') ?>">
<?php endif; ?>
<label for="entry_<?= $gi ?>_<?= $ei ?>_url">Lien (optionnel) :</label>
<input type="url" id="entry_<?= $gi ?>_<?= $ei ?>_url"
name="groups[<?= $gi ?>][entries][<?= $ei ?>][url]"
value="<?= htmlspecialchars($entry['url'] ?? '') ?>">
</div>
<?php endforeach; ?>
<button type="button" class="admin-btn admin-btn--sm add-entry-btn" data-group="<?= $gi ?>">+ Ajouter une entrée</button>
</fieldset>
<?php endforeach; ?>
<button type="button" class="admin-btn" id="add-group-btn">+ Ajouter un <?= $aproposKey === 'contacts' ? 'contact' : 'groupe de crédit' ?></button>
<div class="admin-form-footer">
<button type="submit" class="admin-btn">Enregistrer</button>
<a href="/admin/contenus.php" class="admin-btn-secondary admin-cancel-link">Annuler</a>
</div>
<template id="entry-template-<?= $aproposKey ?>">
<div class="apropos-entry">
<label>Entrée :</label>
<input type="text" name="groups[{{gi}}][entries][{{ei}}][text]">
<?php if ($aproposKey === 'contacts'): ?>
<label>Email :</label>
<input type="email" name="groups[{{gi}}][entries][{{ei}}][email]">
<?php endif; ?>
<label>Lien (optionnel) :</label>
<input type="url" name="groups[{{gi}}][entries][{{ei}}][url]">
</div>
</template>
<template id="group-template-<?= $aproposKey ?>">
<fieldset class="apropos-group">
<legend><?= htmlspecialchars($aproposKey === 'contacts' ? 'Contact' : 'Crédit') ?> {{gi}}</legend>
<?php if ($aproposKey === 'contacts'): ?>
<label>Rôle :</label>
<input type="text" name="groups[{{gi}}][role]">
<?php else: ?>
<label>Label :</label>
<input type="text" name="groups[{{gi}}][label]">
<?php endif; ?>
<button type="button" class="admin-btn admin-btn--sm add-entry-btn" data-group="{{gi}}">+ Ajouter une entrée</button>
</fieldset>
</template>
</form>
<script>
(function() {
const aproposKey = '<?= $aproposKey ?>';
let groupCount = <?= count($groups) ?>;
const entryTpl = document.getElementById('entry-template-' + aproposKey).innerHTML;
const groupTpl = document.getElementById('group-template-' + aproposKey).innerHTML;
// Add entry to a group
document.querySelectorAll('.add-entry-btn').forEach(btn => {
btn.addEventListener('click', function() {
const gi = parseInt(this.dataset.group);
const fieldset = this.closest('fieldset');
const entryCount = fieldset.querySelectorAll('.apropos-entry').length;
const html = entryTpl.replaceAll('{{gi}}', gi).replaceAll('{{ei}}', entryCount);
this.insertAdjacentHTML('beforebegin', html);
});
});
// Add new group
document.getElementById('add-group-btn').addEventListener('click', function() {
groupCount++;
const html = groupTpl.replaceAll('{{gi}}', groupCount);
this.insertAdjacentHTML('beforebegin', html);
// Re-bind add-entry buttons for the new group
const newGroup = this.previousElementSibling;
if (newGroup && newGroup.classList.contains('apropos-group')) {
const btn = newGroup.querySelector('.add-entry-btn');
if (btn) {
btn.dataset.group = groupCount;
btn.addEventListener('click', function() {
const gi = parseInt(this.dataset.group);
const fieldset = this.closest('fieldset');
const entryCount = fieldset.querySelectorAll('.apropos-entry').length;
const html = entryTpl.replaceAll('{{gi}}', gi).replaceAll('{{ei}}', entryCount);
this.insertAdjacentHTML('beforebegin', html);
});
}
}
});
})();
</script>
<?php endif; ?>
</main>
<?php require_once APP_ROOT . "/templates/admin/footer.php"; ?>

View File

@@ -0,0 +1,85 @@
<?php
require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
require_once __DIR__ . '/../../src/Database.php';
$pageTitle = "Contenus";
try {
$db = new Database();
$pages = $db->getAllPages();
$aproposKeys = $db->getAllAproposContents();
} catch (Exception $e) {
error_log("Error loading contenus: " . $e->getMessage());
die("Erreur lors du chargement des contenus.");
}
?>
<?php $isAdmin = true; $bodyClass = 'admin-body'; require_once APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<main id="main-content">
<h1>Contenus</h1>
<h2>Pages statiques</h2>
<table>
<thead>
<tr>
<th scope="col">Slug</th>
<th scope="col">Titre</th>
<th scope="col">Mis à jour</th>
<th scope="col">Action</th>
</tr>
</thead>
<tbody>
<?php foreach ($pages as $p): ?>
<tr>
<td><code><?= htmlspecialchars($p['slug']) ?></code></td>
<td><?= htmlspecialchars($p['title']) ?></td>
<td><?= htmlspecialchars($p['updated_at'] ?? '—') ?></td>
<td>
<a href="/admin/contenus-edit.php?slug=<?= urlencode($p['slug']) ?>"
class="admin-btn admin-btn--sm">Éditer</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<h2 style="margin-top:2rem;">À propos</h2>
<table>
<thead>
<tr>
<th scope="col">Clé</th>
<th scope="col">Type</th>
<th scope="col">Mis à jour</th>
<th scope="col">Action</th>
</tr>
</thead>
<tbody>
<?php foreach ($aproposKeys as $a): ?>
<?php
$typeLabel = match($a['key']) {
'contacts' => 'Contacts',
'credits' => 'Crédits',
};
?>
<tr>
<td><code><?= htmlspecialchars($a['key']) ?></code></td>
<td><?= htmlspecialchars($typeLabel) ?></td>
<td><?= htmlspecialchars($a['updated_at'] ?? '—') ?></td>
<td>
<a href="/admin/contenus-edit.php?apropos=<?= urlencode($a['key']) ?>"
class="admin-btn admin-btn--sm">Éditer</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</main>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

172
app/public/admin/edit.php Normal file
View File

@@ -0,0 +1,172 @@
<?php
// Bootstrap application
require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
// PHP-level auth guard (defence-in-depth behind nginx Basic Auth)
AdminAuth::requireLogin();
// Generate CSRF token
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
require_once APP_ROOT . '/src/Controllers/ThesisEditController.php';
$thesisId = isset($_GET['id']) ? intval($_GET['id']) : 0;
if ($thesisId <= 0) {
die("ID invalide");
}
// WCAG 3.3.1 — consume the autofocus hint stored by the edit action on
// validation failure.
$autofocusField = App::consumeAutofocus();
try {
$ctrl = ThesisEditController::create();
$view = $ctrl->load($thesisId);
extract($view); // thesis, currentLanguages, currentFormats, jury, lookup tables, pageTitle …
} catch (Exception $e) {
error_log("Error loading edit page: " . $e->getMessage());
die("Erreur lors du chargement: " . $e->getMessage());
}
?>
<?php $isAdmin = true; $bodyClass = 'admin-body'; require_once APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<main id="main-content">
<h1>Modifier un TFE</h1>
<form method="post" action="/admin/actions/edit.php" class="admin-form" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="thesis_id" value="<?= $thesisId ?>">
<?php $name = 'auteurice'; $label = 'Auteur·ice(s) :'; $value = htmlspecialchars($thesis['authors']); $required = true; $attrs = array_merge(['autocomplete' => 'name'], $autofocusField === 'auteurice' ? ['autofocus' => true] : []); include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'mail'; $label = 'Contact :'; $value = htmlspecialchars($currentAuthorEmail ?? ''); $attrs = ['autocomplete' => 'email']; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<!-- Contact visibility -->
<div class="admin-form-group">
<label class="admin-checkbox-label">
<input type="checkbox" name="contact_public" value="1"
<?= !empty($currentAuthorShowContact) ? 'checked' : '' ?>>
Je veux que mon contact soit accessible à toustes depuis la plateforme xamxam
</label>
<small>Si cette case est cochée, le contact apparaît sur la page publique du TFE.</small>
</div>
<?php
$name = 'année'; $label = 'Année :'; $value = htmlspecialchars((string)$thesis['year']); $required = true;
$type = 'number';
$attrs = $autofocusField === 'année' ? ['autofocus' => true] : [];
include APP_ROOT . '/templates/partials/form/text-field.php';
?>
<?php $name = 'orientation'; $label = 'Orientation :'; $options = $orientations; $selected = $thesis['orientation']; $required = true; $placeholder = null; include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'ap'; $label = 'Atelier pluridisciplinaire :'; $options = $apPrograms; $selected = $thesis['ap_program']; $required = true; $placeholder = null; include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'finality'; $label = 'Finalité du master :'; $options = $finalityTypes; $selected = $thesis['finality_type']; $required = true; $placeholder = null; include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<!-- Composition du jury -->
<?php
$juryPresident = null;
$juryPromoteur = null;
$juryPromoteurExt = 0;
$juryLecteurs = [];
foreach ($jury as $jm) {
if ($jm['role'] === 'president') {
$juryPresident = $jm['name'];
} elseif ($jm['role'] === 'promoteur') {
$juryPromoteur = $jm['name'];
$juryPromoteurExt = (int)$jm['is_external'];
} elseif ($jm['role'] === 'lecteur') {
$juryLecteurs[] = $jm;
}
}
?>
<?php require APP_ROOT . '/templates/partials/form/jury-fieldset.php'; ?>
<?php
// Access type select: options need 'id'+'name'; description appended inline
$accessOptions = array_map(function($at) {
$label = $at['name'];
if (!empty($at['description'])) {
$label .= ' — ' . $at['description'];
}
return ['id' => $at['id'], 'name' => $label];
}, $accessTypes);
$name = 'access_type_id'; $label = 'Visibilité / Accès :'; $options = $accessOptions; $selected = $currentAccessTypeId; $placeholder = '- Non défini -';
include APP_ROOT . '/templates/partials/form/select-field.php';
?>
<!-- Context note (textarea — no text-field partial for textarea) -->
<div>
<label for="context_note">Note contextuelle :</label>
<div>
<textarea id="context_note" name="context_note"
rows="4" maxlength="1500"><?= htmlspecialchars($currentContextNote ?? '') ?></textarea>
<small>Visible publiquement pour les TFE Interne ou Interdit. Max 1 500 caractères.</small>
</div>
</div>
<?php $name = 'license_id'; $label = 'Licence :'; $options = $licenseTypes; $selected = $currentLicenseId; $placeholder = '- Inconnue -'; include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'titre'; $label = 'Titre :'; $value = htmlspecialchars($thesis['title']); $required = true; $attrs = $autofocusField === 'titre' ? ['autofocus' => true] : []; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'subtitle'; $label = 'Sous-titre :'; $value = htmlspecialchars($thesis['subtitle'] ?? ''); include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<!-- Synopsis (textarea — not covered by text-field partial) -->
<div>
<label for="synopsis">Synopsis :</label>
<textarea id="synopsis" name="synopsis" rows="7" required
<?= $autofocusField === 'synopsis' ? 'autofocus' : '' ?>><?= htmlspecialchars($thesis['synopsis'] ?? '') ?></textarea>
</div>
<?php $name = 'languages'; $label = 'Langue(s) :'; $options = $languages; $checked = $currentLanguages; include APP_ROOT . '/templates/partials/form/checkbox-list.php'; ?>
<?php $name = 'formats'; $label = 'Format(s) :'; $options = $formatTypes; $checked = $currentFormats; include APP_ROOT . '/templates/partials/form/checkbox-list.php'; ?>
<?php $name = 'tag'; $label = 'Mots-clés :'; $value = htmlspecialchars($thesis['keywords'] ?? ''); $hint = 'Séparer par des virgules. Max 10.'; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'duration_info'; $label = 'Durée / Taille :'; $value = htmlspecialchars($thesis['file_size_info'] ?? ''); include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'lien'; $label = 'Lien externe :'; $value = htmlspecialchars($thesis['baiu_link'] ?? ''); $type = 'url'; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<!-- Image bannière (custom: includes current banner preview + remove checkbox) -->
<div>
<label>Image bannière (accueil) :</label>
<div>
<?php if (!empty($thesis['banner_path'])): ?>
<div class="admin-banner-preview">
<img src="/media.php?path=<?= urlencode($thesis['banner_path']) ?>"
alt="Bannière actuelle">
<label class="admin-checkbox-label">
<input type="checkbox" name="remove_banner" value="1"> Supprimer la bannière
</label>
</div>
<?php endif; ?>
<input type="file" name="banner" accept="image/jpeg,image/png,image/webp">
<small>JPG, PNG ou WEBP. Format paysage recommandé (4:1). Max 5 MB.</small>
</div>
</div>
<!-- Publication toggle -->
<div>
<label>Publication :</label>
<label class="admin-checkbox-label">
<input type="checkbox" name="is_published" value="1"
<?= $thesis['is_published'] ? 'checked' : '' ?>>
Publier ce TFE sur le site public
</label>
</div>
<div class="admin-form-footer">
<button type="submit" class="admin-btn">Enregistrer</button>
<a href="/admin/thanks.php?id=<?= $thesisId ?>" class="admin-btn-secondary admin-cancel-link">Annuler</a>
</div>
</form>
</main>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

View File

@@ -0,0 +1,5 @@
<?php
// import.php is no longer a standalone page.
// CSV import is handled inline on the list page via a dialog.
header('Location: /admin/');
exit();

559
app/public/admin/index.php Normal file
View File

@@ -0,0 +1,559 @@
<?php
require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$pageTitle = "Liste des TFE";
require_once __DIR__ . '/../../src/Database.php';
// ── CSV Import (inline, submitted to this same page) ─────────────────────────
$importMessage = '';
$importErrors = [];
$importResults = [];
$importDone = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
if (!isset($_POST['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
$importErrors[] = "Erreur de sécurité : token invalide.";
} else {
$importedCount = 0;
$skippedCount = 0;
try {
$importDb = new Database();
$importPdo = $importDb->getPDO();
if ($_FILES['csv_file']['error'] !== UPLOAD_ERR_OK) {
throw new Exception("Erreur lors du téléversement du fichier.");
}
$handle = fopen($_FILES['csv_file']['tmp_name'], 'r');
if (!$handle) throw new Exception("Impossible d'ouvrir le fichier CSV.");
fgetcsv($handle, 0, ',', '"', '');
fgetcsv($handle, 0, ',', '"', '');
fgetcsv($handle, 0, ',', '"', '');
fgetcsv($handle, 0, ',', '"', ''); // skip 4 header rows
$orientationMap = [
'SC'=>'Sculpture','VI'=>'Vidéographie','CA'=>"Cinéma d'animation",
'IP'=>'Installation-Performance','PE'=>'Peinture','PH'=>'Photographie',
'DE'=>'Dessin','AN'=>'Arts Numériques','GR'=>'Graphisme',
'TY'=>'Typographie','DN'=>'Design Numérique','IL'=>'Illustration',
'BD'=>'Bande-Dessinée','SE'=>'Sérigraphie','GV'=>'Gravure',
];
$lineNumber = 5;
while (($row = fgetcsv($handle, 0, ',', '"', '')) !== false) {
$lineNumber++;
if (empty($row[0]) && empty($row[1])) continue;
try {
$importDb->beginTransaction();
$identifier = trim($row[0] ?? '');
$title = trim($row[1] ?? '');
$subtitle = trim($row[2] ?? '');
$authorsRaw = trim($row[3] ?? '');
$contact = trim($row[4] ?? '');
$supervisorsRaw = trim($row[5] ?? '');
$formatsRaw = trim($row[6] ?? '');
$year = intval($row[7] ?? 0);
$apCode = trim($row[8] ?? '');
$orientationCode = trim($row[9] ?? '');
$finalityName = trim($row[10] ?? '');
$keywordsRaw = trim($row[11] ?? '');
$synopsis = trim($row[12] ?? '');
$context = trim($row[13] ?? '');
$remarks = trim($row[14] ?? '');
$languageRaw = trim($row[15] ?? '');
$access = trim($row[16] ?? '');
$license = trim($row[17] ?? '');
$sizeInfo = trim($row[18] ?? '');
$juryPoints = !empty($row[19]) ? floatval($row[19]) : null;
$baiuLink = trim($row[20] ?? '');
if (empty($title) || empty($year)) throw new Exception("Titre et année requis.");
$orientationName = $orientationMap[$orientationCode] ?? null;
$orientationId = null;
if ($orientationName) {
$s = $importPdo->prepare("SELECT id FROM orientations WHERE name = ?");
$s->execute([$orientationName]);
$r = $s->fetch(); $orientationId = $r ? $r['id'] : null;
}
$apProgramId = null;
if (!empty($apCode)) {
$s = $importPdo->prepare("SELECT id FROM ap_programs WHERE code = ?");
$s->execute([$apCode]);
$r = $s->fetch(); $apProgramId = $r ? $r['id'] : null;
}
$finalityId = null;
if (!empty($finalityName)) {
$s = $importPdo->prepare("SELECT id FROM finality_types WHERE name = ?");
$s->execute([$finalityName]);
$r = $s->fetch(); $finalityId = $r ? $r['id'] : null;
}
$accessTypeId = null;
if (!empty($access)) {
$s = $importPdo->prepare("SELECT id FROM access_types WHERE name = ?");
$s->execute([ucfirst(strtolower($access))]);
$r = $s->fetch(); $accessTypeId = $r ? $r['id'] : null;
}
if ($accessTypeId === null) $accessTypeId = 1;
if (!empty($identifier)) {
$s = $importPdo->prepare("SELECT id FROM theses WHERE identifier = ?");
$s->execute([$identifier]);
if ($s->fetch()) {
$importDb->rollback();
$skippedCount++;
$importResults[] = ['type'=>'skip', 'msg'=>"Ligne $lineNumber: identifiant \"$identifier\" déjà présent, ignoré."];
continue;
}
}
$s = $importPdo->prepare("
INSERT INTO theses (
identifier, title, subtitle, year,
orientation_id, ap_program_id, finality_id,
synopsis, context_note, remarks,
file_size_info, jury_points, baiu_link,
access_type_id, is_published, submitted_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,CURRENT_TIMESTAMP)
");
$s->execute([
!empty($identifier) ? $identifier : null, $title,
!empty($subtitle) ? $subtitle : null, $year,
$orientationId, $apProgramId, $finalityId,
!empty($synopsis) ? $synopsis : null,
!empty($context) ? $context : null,
!empty($remarks) ? $remarks : null,
!empty($sizeInfo) ? $sizeInfo : null,
$juryPoints,
!empty($baiuLink) ? $baiuLink : null,
$accessTypeId,
]);
$thesisId = $importPdo->lastInsertId();
if (!empty($authorsRaw)) {
foreach (array_map('trim', explode(',', $authorsRaw)) as $idx => $name) {
if ($name) {
$aId = $importDb->findOrCreateAuthor($name, $idx === 0 ? $contact : null);
$s = $importPdo->prepare("INSERT INTO thesis_authors (thesis_id, author_id, author_order) VALUES (?,?,?)");
$s->execute([$thesisId, $aId, $idx + 1]);
}
}
}
if (!empty($supervisorsRaw)) {
foreach (array_map('trim', explode(',', $supervisorsRaw)) as $idx => $name) {
if ($name) {
$sId = $importDb->findOrCreateSupervisor($name);
$s = $importPdo->prepare("INSERT INTO thesis_supervisors (thesis_id, supervisor_id, supervisor_order) VALUES (?,?,?)");
$s->execute([$thesisId, $sId, $idx + 1]);
}
}
}
if (!empty($keywordsRaw)) {
foreach (array_slice(array_map('trim', explode(',', $keywordsRaw)), 0, 10) as $kw) {
if ($kw) {
$tId = $importDb->findOrCreateTag($kw);
if ($tId) {
$s = $importPdo->prepare("INSERT INTO thesis_tags (thesis_id, tag_id) VALUES (?,?)");
$s->execute([$thesisId, $tId]);
}
}
}
}
if (!empty($languageRaw)) {
$s = $importPdo->prepare("SELECT id FROM languages WHERE name = ?");
$s->execute([ucfirst(strtolower($languageRaw))]);
$r = $s->fetch();
if ($r) {
$s2 = $importPdo->prepare("INSERT INTO thesis_languages (thesis_id, language_id) VALUES (?,?)");
$s2->execute([$thesisId, $r['id']]);
}
}
if (!empty($formatsRaw)) {
foreach (array_map('trim', explode(',', $formatsRaw)) as $fmt) {
if ($fmt) {
$s = $importPdo->prepare("SELECT id FROM format_types WHERE name = ?");
$s->execute([ucfirst(strtolower($fmt))]);
$r = $s->fetch();
if ($r) {
$s2 = $importPdo->prepare("INSERT INTO thesis_formats (thesis_id, format_id) VALUES (?,?)");
$s2->execute([$thesisId, $r['id']]);
}
}
}
}
$importDb->commit();
$importedCount++;
$importResults[] = ['type'=>'ok', 'msg'=>"\"$title\" (ID: $thesisId)"];
} catch (Exception $e) {
$importDb->rollback();
$skippedCount++;
$importResults[] = ['type'=>'error', 'msg'=>"Ligne $lineNumber: " . $e->getMessage()];
error_log("Import error on line $lineNumber: " . $e->getMessage());
}
}
fclose($handle);
$importMessage = "Import terminé : $importedCount TFE importés, $skippedCount ignorés.";
$importDone = true;
} catch (Exception $e) {
$importErrors[] = $e->getMessage();
error_log("CSV import error: " . $e->getMessage());
}
}
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
try {
$db = new Database();
$searchQuery = isset($_GET['search']) ? trim($_GET['search']) : '';
$yearFilter = isset($_GET['year']) ? intval($_GET['year']) : null;
$orientationFilter = isset($_GET['orientation']) ? intval($_GET['orientation']) : null;
$apFilter = isset($_GET['ap']) ? intval($_GET['ap']) : null;
$sortCol = isset($_GET['sort']) ? trim($_GET['sort']) : 'submitted_at';
$sortDir = isset($_GET['dir']) ? trim($_GET['dir']) : 'desc';
$filters = [];
if ($searchQuery) $filters['search'] = $searchQuery;
if ($yearFilter) $filters['year'] = $yearFilter;
if ($orientationFilter) $filters['orientation'] = $orientationFilter;
if ($apFilter) $filters['ap'] = $apFilter;
$filters['sort'] = $sortCol;
$filters['dir'] = $sortDir;
$perPage = 25;
$page = isset($_GET['page']) ? max(1, intval($_GET['page'])) : 1;
$totalCount = $db->getThesesListCount($filters);
$totalPages = $totalCount > 0 ? (int) ceil($totalCount / $perPage) : 1;
$page = min($page, $totalPages);
$offset = ($page - 1) * $perPage;
$theses = $db->getThesesList($filters, $perPage, $offset);
$stats = $db->getThesesStats();
$years = $db->getAllYears();
$orientations = $db->getAllOrientations();
$apPrograms = $db->getAllAPPrograms();
} catch (Exception $e) {
error_log("Error loading theses list: " . $e->getMessage());
die("Erreur lors du chargement de la liste.");
}
?>
<?php $isAdmin = true; $bodyClass = 'admin-body'; require_once APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<script>
function toggleAll(src) {
document.querySelectorAll('input[name="selected_theses[]"]').forEach(cb => cb.checked = src.checked);
updateBulk();
}
function updateBulk() {
const checked = document.querySelectorAll('input[name="selected_theses[]"]:checked');
const bulk = document.getElementById('bulk-actions');
document.getElementById('selected-count').textContent = checked.length;
bulk.style.display = checked.length > 0 ? 'flex' : 'none';
}
function bulkAction(action) {
const checked = document.querySelectorAll('input[name="selected_theses[]"]:checked');
if (!checked.length) { alert('Sélectionnez au moins un TFE.'); return; }
let word, endpoint;
if (action === 'publish') { word = 'publier'; endpoint = 'actions/publish.php'; }
else if (action === 'unpublish') { word = 'dépublier'; endpoint = 'actions/publish.php'; }
else if (action === 'delete') { word = 'supprimer'; endpoint = 'actions/delete.php'; }
else return;
if (action === 'delete') {
if (!confirm(`Supprimer définitivement ${checked.length} TFE(s) ? Cette action est irréversible.`)) return;
} else {
if (!confirm(`${word.charAt(0).toUpperCase()+word.slice(1)} ${checked.length} TFE(s) ?`)) return;
}
document.getElementById('bulk-action-input').value = action;
document.getElementById('bulk-form').action = endpoint;
const container = document.getElementById('bulk-checkboxes');
container.innerHTML = '';
checked.forEach(cb => {
const inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'selected_theses[]'; inp.value = cb.value;
container.appendChild(inp);
});
document.getElementById('bulk-form').submit();
}
function deleteThesis(id, title) {
if (!confirm(`Supprimer « ${title} » ?\nCette action est irréversible.`)) return;
const form = document.getElementById('delete-form-' + id);
if (form) form.submit();
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('input[name="selected_theses[]"]').forEach(cb => cb.addEventListener('change', updateBulk));
});
</script>
<main id="main-content">
<!-- Title + filters + stats + import all in one toolbar row -->
<div class="admin-list-toolbar">
<h1>Liste des TFE</h1>
<form class="admin-filters" method="get" action="/admin/">
<input type="text" name="search" placeholder="Titre, auteur..."
value="<?= htmlspecialchars($searchQuery) ?>">
<select name="year">
<option value="">Année</option>
<?php foreach ($years as $y): ?>
<option value="<?= $y ?>" <?= $yearFilter == $y ? 'selected' : '' ?>><?= $y ?></option>
<?php endforeach; ?>
</select>
<select name="orientation">
<option value="">Orientation</option>
<?php foreach ($orientations as $o): ?>
<option value="<?= $o['id'] ?>" <?= $orientationFilter == $o['id'] ? 'selected' : '' ?>>
<?= htmlspecialchars($o['name']) ?>
</option>
<?php endforeach; ?>
</select>
<select name="ap">
<option value="">AP</option>
<?php foreach ($apPrograms as $ap): ?>
<option value="<?= $ap['id'] ?>" <?= $apFilter == $ap['id'] ? 'selected' : '' ?>>
<?= htmlspecialchars($ap['name']) ?>
</option>
<?php endforeach; ?>
</select>
<button type="submit" class="admin-filters-btn">Filtrer</button>
<?php if ($searchQuery || $yearFilter || $orientationFilter || $apFilter): ?>
<button type="button" class="admin-filters-reset"
onclick="window.location='/admin/'">&#x2715; Réinitialiser</button>
<?php endif; ?>
</form>
<div class="admin-list-toolbar__right">
<dl class="admin-stats">
<div class="admin-stat">
<dt class="admin-stat__label">Total</dt>
<dd class="admin-stat__number"><?= $stats['total'] ?></dd>
</div>
<div class="admin-stat">
<dt class="admin-stat__label">Publiés</dt>
<dd class="admin-stat__number"><?= $stats['published'] ?></dd>
</div>
<div class="admin-stat">
<dt class="admin-stat__label">Attente</dt>
<dd class="admin-stat__number"><?= $stats['pending'] ?></dd>
</div>
</dl>
<a href="/admin/add.php" class="admin-btn admin-btn--sm">Ajouter un TFE</a>
<button type="button" class="admin-btn admin-btn--sm" id="import-dialog-btn"
onclick="document.getElementById('import-dialog').showModal()">
Importer un CSV
</button>
<a href="/admin/actions/export-csv.php" class="admin-btn admin-btn--sm">
Exporter CSV
</a>
</div>
</div>
<!-- Bulk actions bar -->
<div id="bulk-actions" class="admin-bulk-actions" role="toolbar" aria-label="Actions groupées">
<strong><span id="selected-count">0</span> TFE(s) sélectionné(s)</strong>
<div class="admin-bulk-btns">
<button type="button" class="admin-btn-sm admin-btn-publish" onclick="bulkAction('publish')">Publier</button>
<button type="button" class="admin-btn-sm admin-btn-unpublish" onclick="bulkAction('unpublish')">Dépublier</button>
<button type="button" class="admin-btn-sm admin-btn-delete" onclick="bulkAction('delete')">Supprimer</button>
</div>
</div>
<form id="bulk-form" method="post" action="actions/publish.php">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" id="bulk-action-input" name="action" value="">
<input type="hidden" name="bulk" value="1">
<div id="bulk-checkboxes"></div>
</form>
<!-- Table -->
<?php if (empty($theses)): ?>
<p class="admin-empty">Aucun TFE trouvé.</p>
<?php else: ?>
<p class="admin-list-meta">
<?php
$from = $offset + 1;
$to = min($offset + $perPage, $totalCount);
if ($totalPages > 1) {
echo "{$from}-{$to} sur {$totalCount} TFE";
} else {
echo "$totalCount TFE";
}
?>
</p>
<?php
$sortParams = array_filter([
'search' => $searchQuery,
'year' => $yearFilter ?: '',
'orientation' => $orientationFilter ?: '',
'ap' => $apFilter ?: '',
]);
$sortLink = function(string $col) use ($sortCol, $sortDir, $sortParams): string {
$params = $sortParams;
$params['sort'] = $col;
$params['dir'] = ($sortCol === $col && $sortDir === 'desc') ? 'asc' : 'desc';
return '/admin/?' . http_build_query($params);
};
$sortArrow = function(string $col) use ($sortCol, $sortDir): string {
if ($sortCol !== $col) return '';
return $sortDir === 'asc' ? ' ↑' : ' ↓';
};
?>
<table>
<thead>
<tr>
<th scope="col"><input type="checkbox" onchange="toggleAll(this)"></th>
<th scope="col"><a href="<?= $sortLink('identifier') ?>" class="admin-sort-link">ID<?= $sortArrow('identifier') ?></a></th>
<th scope="col"><a href="<?= $sortLink('title') ?>" class="admin-sort-link">Titre<?= $sortArrow('title') ?></a></th>
<th scope="col">Auteur(s)</th>
<th scope="col"><a href="<?= $sortLink('year') ?>" class="admin-sort-link">Année<?= $sortArrow('year') ?></a></th>
<th scope="col"><a href="<?= $sortLink('orientation') ?>" class="admin-sort-link">Orientation<?= $sortArrow('orientation') ?></a></th>
<th scope="col"><a href="<?= $sortLink('ap_program') ?>" class="admin-sort-link">AP<?= $sortArrow('ap_program') ?></a></th>
<th scope="col"><a href="<?= $sortLink('is_published') ?>" class="admin-sort-link">Statut<?= $sortArrow('is_published') ?></a></th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($theses as $thesis): ?>
<tr>
<td><input type="checkbox" name="selected_theses[]" value="<?= $thesis['id'] ?>"></td>
<td class="admin-table-id"><?= htmlspecialchars($thesis['identifier'] ?? $thesis['id']) ?></td>
<td>
<div class="thesis-title"><?= htmlspecialchars($thesis['title']) ?></div>
<?php if ($thesis['subtitle']): ?>
<div class="thesis-subtitle"><?= htmlspecialchars($thesis['subtitle']) ?></div>
<?php endif; ?>
</td>
<td><?= htmlspecialchars($thesis['authors'] ?? 'N/A') ?></td>
<td><?= $thesis['year'] ?></td>
<td><?= htmlspecialchars($thesis['orientation'] ?? 'N/A') ?></td>
<td><?= htmlspecialchars($thesis['ap_program'] ?? 'N/A') ?></td>
<td>
<?php $badgeType = 'publish'; $badgeValue = $thesis['is_published']; include APP_ROOT . '/templates/partials/status-badge.php'; ?>
<?php if (!empty($thesis['access_type'])): ?>
<br><?php $badgeType = 'access'; $badgeValue = $thesis['access_type']; include APP_ROOT . '/templates/partials/status-badge.php'; ?>
<?php endif; ?>
</td>
<td>
<div class="admin-actions">
<a href="/admin/thanks.php?id=<?= $thesis['id'] ?>" class="admin-btn-sm admin-btn-view">Voir</a>
<a href="/admin/edit.php?id=<?= $thesis['id'] ?>" class="admin-btn-sm admin-btn-edit">Éditer</a>
<form method="post" action="actions/publish.php" class="publish-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="thesis_id" value="<?= $thesis['id'] ?>">
<?php if ($thesis['is_published']): ?>
<input type="hidden" name="action" value="unpublish">
<button type="submit" class="admin-btn-sm admin-btn-unpublish"
onclick="return confirm('Retirer de la publication ?')">Dépublier</button>
<?php else: ?>
<input type="hidden" name="action" value="publish">
<button type="submit" class="admin-btn-sm admin-btn-publish">Publier</button>
<?php endif; ?>
</form>
<form method="post" action="actions/delete.php" id="delete-form-<?= $thesis['id'] ?>" class="publish-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="thesis_id" value="<?= $thesis['id'] ?>">
<button type="button" class="admin-btn-sm admin-btn-delete"
onclick="deleteThesis(<?= $thesis['id'] ?>, <?= htmlspecialchars(json_encode($thesis['title']), ENT_QUOTES) ?>)">Supprimer</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php
$baseParams = array_filter([
'search' => $searchQuery,
'year' => $yearFilter ?: '',
'orientation' => $orientationFilter ?: '',
'ap' => $apFilter ?: '',
'sort' => $sortCol,
'dir' => $sortDir,
]);
include APP_ROOT . '/templates/partials/pagination.php';
?>
</main>
<!-- ══════════════════════════════════════════════════════════════
IMPORT DIALOG
══════════════════════════════════════════════════════════════ -->
<dialog id="import-dialog" class="admin-dialog" aria-labelledby="import-dialog-title">
<div class="admin-dialog__header">
<h2 id="import-dialog-title">Importer une liste de TFE</h2>
<button type="button" class="admin-dialog__close" aria-label="Fermer"
onclick="document.getElementById('import-dialog').close()">&#x2715;</button>
</div>
<?php if ($importMessage || !empty($importErrors)): ?>
<div class="admin-import-status-card">
<?php if (!empty($importErrors)): ?>
<div class="toast admin-import-status-card__errors" role="alert" data-type="error">
<strong>⚠ Erreurs :</strong>
<ul class="admin-error-list">
<?php foreach ($importErrors as $err): ?>
<li><?= htmlspecialchars($err) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php if ($importMessage): ?>
<p class="toast admin-import-status-card__success" role="status" data-type="success">✓ <?= htmlspecialchars($importMessage) ?></p>
<?php endif; ?>
</div>
<?php endif; ?>
<form method="post" enctype="multipart/form-data" class="admin-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<div>
<label for="csv_file">Fichier CSV</label>
<div class="admin-file-input">
<input type="file" id="csv_file" name="csv_file" accept=".csv" required>
<small class="admin-file-hint">
Colonnes : Identifiant, Titre, Sous-titre, Auteur·ice(s), Contact, Promoteur·ice(s), Format, Année, AP, Orientation, Finalité, Mots-clés, Synopsis, Contexte, Remarques, Langue, Autorisation, License, taille, Points sur 20, lien BAIU<br>
Quatre premières lignes ignorées — Séparateur : virgule — UTF-8
</small>
</div>
</div>
<div class="admin-form-footer">
<button type="submit" class="admin-btn">Importer</button>
<button type="button" class="admin-btn-secondary"
onclick="document.getElementById('import-dialog').close()">Annuler</button>
</div>
</form>
<?php if (!empty($importResults)): ?>
<details class="admin-import-log-details">
<summary>Logs d'importation (<?= count($importResults) ?> entrées)</summary>
<ul class="admin-import-log">
<?php foreach ($importResults as $r): ?>
<li class="admin-import-log__item admin-import-log__item--<?= $r['type'] ?>"><?= htmlspecialchars($r['msg']) ?></li>
<?php endforeach; ?>
</ul>
</details>
<?php endif; ?>
</dialog>
<?php if ($importMessage || !empty($importErrors)): ?>
<script>document.getElementById('import-dialog').showModal();</script>
<?php endif; ?>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

View File

@@ -0,0 +1,49 @@
<?php
require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php';
if (!AdminAuth::hasPassword()) {
header('Location: /admin/');
exit;
}
if (AdminAuth::isAuthenticated()) {
header('Location: /admin/');
exit;
}
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$password = $_POST['password'] ?? '';
if (AdminAuth::login($password)) {
header('Location: /admin/');
exit;
}
$error = 'Mot de passe incorrect.';
}
$pageTitle = 'Connexion';
?>
<?php $isAdmin = true; $bodyClass = 'admin-body'; require_once APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<main id="main-content">
<div class="admin-login-wrap">
<div class="admin-login-box">
<h2>Administration</h2>
<?php if ($error): ?>
<p class="toast" role="alert" data-type="error">⚠ <?= htmlspecialchars($error) ?></p>
<?php endif; ?>
<form method="post" action="/admin/login.php" class="admin-form">
<div>
<label for="password">Mot de passe</label>
<input type="password" id="password" name="password" required autofocus>
</div>
<div class="admin-form-footer">
<button type="submit" class="admin-btn">Se connecter</button>
</div>
</form>
</div>
</div>
</main>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

View File

@@ -0,0 +1,8 @@
<?php
require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::logout();
header('Location: /admin/login.php');
exit;

View File

@@ -0,0 +1,4 @@
<?php
// Redirects legacy /admin/logs.php → /admin/system.php?tab=nginx_access
header('Location: /admin/system.php?tab=nginx_access', true, 301);
exit;

View File

@@ -0,0 +1,317 @@
<?php
require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
$pageTitle = "Paramètres";
$hasPassword = AdminAuth::hasPassword();
$maintenanceOn = file_exists(APP_ROOT . '/storage/maintenance.flag');
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/SmtpRelay.php';
$db = new Database();
$siteSettings = $db->getAllSettings();
$stats = $db->getThesesStats();
$smtpSettings = SmtpRelay::getSettings($db);
$smtpConfigured = SmtpRelay::isConfigured($db);
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
?>
<?php $isAdmin = true; $bodyClass = 'admin-body'; require_once APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<main id="main-content">
<h1>Paramètres</h1>
<!-- ══════════════════════════════════════════════════════════════
MAINTENANCE
══════════════════════════════════════════════════════════════ -->
<section aria-labelledby="settings-maintenance-title">
<h2 id="settings-maintenance-title">Maintenance</h2>
<div class="param-maintenance-row">
<?php if ($maintenanceOn): ?>
<p>
<strong>⚠ Mode maintenance activé</strong> — le site public est inaccessible.
</p>
<form method="post" action="actions/maintenance.php">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="disable_maintenance">
<input type="hidden" name="redirect" value="/admin/parametres.php">
<button type="submit">Désactiver la maintenance</button>
</form>
<?php else: ?>
<p>Site public : <strong>en ligne</strong></p>
<form method="post" action="actions/maintenance.php">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="enable_maintenance">
<input type="hidden" name="redirect" value="/admin/parametres.php">
<button type="submit" class="param-btn-warning"
onclick="return confirm('Mettre le site en maintenance ? Les visiteurs verront une page 503.')">
Activer la maintenance
</button>
</form>
<?php endif; ?>
</div>
<!-- Export database -->
<fieldset class="param-export-zone">
<legend>Exporter la base de données</legend>
<p>Télécharger une copie complète de la base de données SQLite.
Cela inclut tous les TFE, auteurs, jury, mots-clés, paramètres, etc.</p>
<button type="button" class="param-btn-export"
onclick="document.getElementById('export-db-dialog').showModal()">
Exporter la base de données
</button>
</fieldset>
<!-- Danger zone: delete all TFE → now inside maintenance -->
<fieldset class="param-danger-zone">
<legend>Supprimer tous les TFE</legend>
<p>
Supprime définitivement tous les TFE de la base de données, y compris auteurs,
promoteurs, tags, fichiers associés. Cette action est <strong>irréversible</strong>.
</p>
<form method="post" action="actions/delete.php"
onsubmit="return confirm('⚠ Supprimer définitivement TOUS les TFE ? Cette action est IRRÉVERSIBLE.')">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="delete_all" value="1">
<button type="submit" class="param-btn-danger">Supprimer tous les TFE (<?= $stats['total'] ?? '?' ?>)</button>
</form>
</fieldset>
</section>
<!-- ══════════════════════════════════════════════════════════════
FORMULAIRE
══════════════════════════════════════════════════════════════ -->
<section aria-labelledby="settings-formulaire-title">
<h2 id="settings-formulaire-title">Formulaire</h2>
<p>Options de visibilité disponibles dans le formulaire d'ajout de TFE.</p>
<p class="param-note">L'option <strong>Libre</strong> ne sera activée qu'à partir de l'année académique prochaine.</p>
<form method="post" action="actions/settings.php" class="param-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="section" value="formulaire">
<fieldset>
<legend>Types d'accès</legend>
<label class="param-checkbox">
<input type="checkbox" name="access_type_interdit_enabled" value="1"
<?= ($siteSettings['access_type_interdit_enabled'] ?? '1') === '1' ? 'checked' : '' ?>>
<span>
<strong>Interdit</strong><br>
<small>TFE non disponible en physique ni sur le site</small>
</span>
</label>
<label class="param-checkbox">
<input type="checkbox" name="access_type_interne_enabled" value="1"
<?= ($siteSettings['access_type_interne_enabled'] ?? '1') === '1' ? 'checked' : '' ?>>
<span>
<strong>Interne</strong><br>
<small>TFE accessible uniquement sur place en physique</small>
</span>
</label>
<label class="param-checkbox param-checkbox--disabled">
<input type="checkbox" name="access_type_libre_enabled" value="1"
<?= ($siteSettings['access_type_libre_enabled'] ?? '0') === '1' ? 'checked' : '' ?>>
<span>
<strong>Libre</strong><br>
<small>Libre accès — disponible à partir de l'année académique prochaine</small>
</span>
</label>
</fieldset>
<button type="submit">Enregistrer</button>
</form>
</section>
<!-- ══════════════════════════════════════════════════════════════
RELAY SMTP
══════════════════════════════════════════════════════════════ -->
<section aria-labelledby="settings-smtp-title">
<h2 id="settings-smtp-title">Relay SMTP</h2>
<p>
Identifiants du serveur SMTP utilisé pour l'envoi d'e-mails
(notifications, partage de TFE, etc.).
</p>
<div class="param-smtp-status">
<?php if ($smtpConfigured): ?>
<span class="param-badge-ok">✓ Configuré</span>
<span><?= htmlspecialchars($smtpSettings['host']) ?>:<?= (int)$smtpSettings['port'] ?> (<?= htmlspecialchars($smtpSettings['encryption']) ?>)</span>
<?php else: ?>
<span class="param-badge-warn">✗ Non configuré</span>
<?php endif; ?>
</div>
<form method="post" action="actions/settings.php" class="param-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="section" value="smtp">
<div class="param-grid">
<div>
<label for="smtp_host">Hôte SMTP</label>
<input type="text" id="smtp_host" name="smtp_host"
value="<?= htmlspecialchars($smtpSettings['host']) ?>"
placeholder="smtp.example.com">
</div>
<div>
<label for="smtp_port">Port</label>
<input type="number" id="smtp_port" name="smtp_port"
value="<?= (int)$smtpSettings['port'] ?>"
min="1" max="65535">
</div>
<div>
<label for="smtp_encryption">Chiffrement</label>
<select id="smtp_encryption" name="smtp_encryption">
<option value="tls" <?= $smtpSettings['encryption'] === 'tls' ? 'selected' : '' ?>>TLS (STARTTLS)</option>
<option value="ssl" <?= $smtpSettings['encryption'] === 'ssl' ? 'selected' : '' ?>>SSL (SMTPS)</option>
<option value="none" <?= $smtpSettings['encryption'] === 'none' ? 'selected' : '' ?>>Aucun</option>
</select>
</div>
<div>
<label for="smtp_username">Nom d'utilisateur</label>
<input type="text" id="smtp_username" name="smtp_username"
value="<?= htmlspecialchars($smtpSettings['username']) ?>">
</div>
<div>
<label for="smtp_password">Mot de passe</label>
<input type="password" id="smtp_password" name="smtp_password"
value="<?= htmlspecialchars($smtpSettings['password']) ?>"
autocomplete="new-password"
placeholder="Laissez vide pour ne pas modifier">
</div>
</div>
<fieldset class="param-fieldset-inline">
<legend>Expéditeur par défaut</legend>
<div class="param-grid">
<div>
<label for="smtp_from_email">Adresse e-mail</label>
<input type="email" id="smtp_from_email" name="smtp_from_email"
value="<?= htmlspecialchars($smtpSettings['from_email']) ?>"
placeholder="noreply@example.com">
</div>
<div>
<label for="smtp_from_name">Nom d'expéditeur</label>
<input type="text" id="smtp_from_name" name="smtp_from_name"
value="<?= htmlspecialchars($smtpSettings['from_name']) ?>">
</div>
</div>
</fieldset>
<button type="submit">Enregistrer</button>
</form>
</section>
<!-- ══════════════════════════════════════════════════════════════
COMPTE ADMINISTRATEUR
══════════════════════════════════════════════════════════════ -->
<section aria-labelledby="settings-account-title">
<h2 id="settings-account-title">Compte administrateur</h2>
<dl class="param-account-status">
<div>
<dt>Authentification PHP</dt>
<dd><?php $badgeType = 'ok'; $badgeValue = $hasPassword; $badgeOkLabel = 'Active'; $badgeWarnLabel = 'Non configurée'; include APP_ROOT . '/templates/partials/status-badge.php'; ?></dd>
</div>
<div>
<dt>Stockage du hash</dt>
<dd>
<code>site_settings (DB)</code>
<?php $badgeType = 'ok'; $badgeValue = $hasPassword; $badgeOkLabel = 'Présent'; $badgeWarnLabel = 'Absent'; include APP_ROOT . '/templates/partials/status-badge.php'; ?>
</dd>
</div>
</dl>
<?php if (!$hasPassword): ?>
<p class="param-note">
Aucun mot de passe PHP configuré. Le formulaire ci-dessous stockera
un hash bcrypt dans la base de données.
</p>
<?php endif; ?>
<form method="post" action="/admin/actions/account.php" class="param-form" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="redirect" value="/admin/parametres.php">
<?php if ($hasPassword): ?>
<div>
<label for="current_password">Mot de passe actuel</label>
<input type="password" id="current_password"
name="current_password" required autocomplete="current-password">
</div>
<?php endif; ?>
<div>
<label for="new_password">Nouveau mot de passe</label>
<input type="password" id="new_password"
name="new_password" required autocomplete="new-password"
minlength="12">
<small>Minimum 12 caractères.</small>
</div>
<div>
<label for="confirm_password">Confirmer le mot de passe</label>
<input type="password" id="confirm_password"
name="confirm_password" required autocomplete="new-password">
</div>
<button type="submit">
<?= $hasPassword ? 'Mettre à jour le mot de passe' : 'Définir le mot de passe' ?>
</button>
</form>
<!-- Danger zone: remove credentials -->
<?php if ($hasPassword): ?>
<fieldset class="param-danger-zone">
<legend>Supprimer la configuration du mot de passe PHP</legend>
<p>
Supprime le hash de la base de données. L'accès admin
dépendra uniquement de l'authentification nginx Basic Auth si elle est configurée.
</p>
<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.')">>
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="remove_credentials">
<input type="hidden" name="redirect" value="/admin/parametres.php">
<input type="hidden" name="current_password_remove" value="">
<button type="submit" class="param-btn-danger">Supprimer le mot de passe</button>
</form>
</fieldset>
<?php endif; ?>
</section>
<!-- ══════════════════════════════════════════════════════════════════
EXPORT DATABASE DIALOG
═══════════════════════════════════════════════════════════════ -->
<dialog id="export-db-dialog" class="admin-dialog" aria-labelledby="export-db-dialog-title">
<div class="admin-dialog__header">
<h2 id="export-db-dialog-title">Exporter la base de données</h2>
<button type="button" class="admin-dialog__close" aria-label="Fermer"
onclick="document.getElementById('export-db-dialog').close()">&#x2715;</button>
</div>
<p>Télécharger une copie complète de la base de données SQLite.
Cela inclut tous les TFE, auteurs, jury, mots-clés, paramètres, etc.</p>
<div class="admin-form-footer">
<a href="/admin/actions/export-db.php" class="admin-btn">Exporter la base de données</a>
<button type="button" class="admin-btn-secondary"
onclick="document.getElementById('export-db-dialog').close()">Annuler</button>
</div>
</dialog>
</main>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

View File

@@ -0,0 +1,4 @@
<?php
// Redirects legacy /admin/status.php → /admin/system.php?tab=status
header('Location: /admin/system.php?tab=status', true, 301);
exit;

View File

@@ -0,0 +1,146 @@
<?php
/**
* system-fragment.php — returns only the tab-panel HTML for the admin system page.
*
* Called by fetch() from system.php JS when switching tabs or changing line count.
* With JS disabled the user never hits this URL directly; the tab <a> hrefs still
* point at system.php?tab=… so navigation degrades gracefully.
*
* Response: text/html fragment (no <html>/<head>/<body> wrapper).
* On any auth failure or bad request: 403 / 400 with a plain-text body.
*/
require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/SystemCache.php';
require_once APP_ROOT . '/src/Controllers/SystemController.php';
if (!AdminAuth::isAuthenticated()) {
http_response_code(403);
header('Content-Type: text/plain; charset=utf-8');
echo 'Non autorisé';
exit;
}
// ── Validate inputs ────────────────────────────────────────────────────────
$tab = $_GET['tab'] ?? 'nginx_access';
if ($tab !== 'nginx_config' && !array_key_exists($tab, SystemController::LOG_FILES)) {
$tab = 'nginx_access';
}
$n = isset($_GET['n']) ? (int) $_GET['n'] : 100;
if (!in_array($n, SystemController::ALLOWED_LINES, true)) {
$n = 100;
}
header('Content-Type: text/html; charset=utf-8');
header('X-Robots-Tag: noindex');
// ── Build data via controller ──────────────────────────────────────────────
$_db = new Database();
$_cache = new SystemCache($_db->getPDO());
$_controller = new SystemController($_db, $_cache);
// ── Render ─────────────────────────────────────────────────────────────────
if ($tab === 'nginx_config') {
$data = $_controller->getNginxConfigData();
$lines = $data['lines'];
$source = $data['source'];
$meta = $data['meta'];
$error = $data['error'];
if ($meta): ?>
<div class="log-meta">
<span data-label="Fichier"><?= htmlspecialchars($meta['path']) ?></span>
<span data-label="Taille"><?= $meta['size'] ?></span>
<span data-label="Modifié"><?= $meta['mtime'] ?></span>
<?php if ($source === 'live'): ?>
<span class="nginx-source-badge nginx-source-badge--live">● Config déployée</span>
<?php else: ?>
<span class="nginx-source-badge nginx-source-badge--local">⚠ Référence locale (config live inaccessible)</span>
<?php endif; ?>
</div>
<?php endif;
if ($error !== null): ?>
<div class="log-unavailable">
<strong>Configuration nginx non disponible</strong>
<div class="log-unavail-path"><?= htmlspecialchars($error) ?></div>
<?php if (php_sapi_name() === 'cli-server'): ?>
<div class="log-unavail-dev">
En développement, <code>/etc/nginx/sites-available/posterg</code> n'existe pas.
La config de référence se trouve dans <code>nginx/posterg.conf</code>.
</div>
<?php endif; ?>
</div>
<?php elseif (empty($lines)): ?>
<div class="log-empty">Le fichier de configuration est vide.</div>
<?php else: ?>
<div class="log-output" id="log-output" role="region" aria-label="Configuration nginx">
<button class="log-copy-btn" id="log-copy-btn" type="button" title="Copier la configuration"
onclick="copyLogContent(this);return false">Copier</button>
<?php foreach ($lines as $i => $line): ?>
<span class="log-line <?= SystemController::nginxLineClass($line) ?>"
data-n="<?= $i + 1 ?>"><?= htmlspecialchars($line, ENT_QUOTES | ENT_SUBSTITUTE) ?></span>
<?php endforeach; ?>
</div>
<?php endif;
} else {
// ── Log tab ────────────────────────────────────────────────────────────
$data = $_controller->getLogData($tab, $n);
$logLines = $data['lines'];
$logError = $data['error'];
$logMeta = $data['meta'];
?>
<div class="log-toolbar">
<form id="lines-form" hx-get="/admin/system-fragment.php"
hx-target="#sys-tab-panel"
hx-swap="innerHTML"
hx-indicator="#sys-tab-panel"
hx-trigger="change"
hx-vals='{"tab":"<?= htmlspecialchars($tab) ?>"}'>
<label for="lines-select">Afficher</label>
<select id="lines-select" name="n" aria-label="Nombre de lignes">
<?php foreach (SystemController::ALLOWED_LINES as $opt): ?>
<option value="<?= $opt ?>" <?= $opt === $n ? 'selected' : '' ?>><?= $opt ?> dernières lignes</option>
<?php endforeach; ?>
</select>
</form>
<?php if ($logLines !== null && count($logLines) > 0): ?>
<span class="log-count-badge"><?= count($logLines) ?> ligne(s)</span>
<?php endif; ?>
</div>
<?php if ($logMeta): ?>
<div class="log-meta">
<span data-label="Fichier"><?= htmlspecialchars($logMeta['path']) ?></span>
<span data-label="Taille"><?= $logMeta['size'] ?></span>
<span data-label="Modifié"><?= $logMeta['mtime'] ?></span>
</div>
<?php endif; ?>
<?php if ($logError !== null): ?>
<div class="log-unavailable">
<strong>Journaux non disponibles</strong>
<div class="log-unavail-path"><?= $logError ?></div>
<?php if (php_sapi_name() === 'cli-server'): ?>
<div class="log-unavail-dev">
En environnement de développement, les logs nginx ne sont pas disponibles.
Cette page est pleinement fonctionnelle sur le serveur de production.
</div>
<?php endif; ?>
</div>
<?php elseif (empty($logLines)): ?>
<div class="log-empty">Le fichier journal est vide.</div>
<?php else: ?>
<div class="log-output" id="log-output" role="log" aria-live="off" aria-label="Contenu du journal">
<button class="log-copy-btn" id="log-copy-btn" type="button" title="Copier le contenu"
onclick="copyLogContent(this);return false">Copier</button>
<?php foreach ($logLines as $i => $line): ?>
<span class="log-line <?= SystemController::logLineClass($line) ?>"
data-n="<?= count($logLines) - $i ?>"><?= htmlspecialchars($line, ENT_QUOTES | ENT_SUBSTITUTE) ?></span>
<?php endforeach; ?>
</div>
<?php endif;
}

358
app/public/admin/system.php Normal file
View File

@@ -0,0 +1,358 @@
<?php
require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/SystemCache.php';
require_once APP_ROOT . '/src/Controllers/SystemController.php';
AdminAuth::requireLogin();
$pageTitle = "Système";
$_db = new Database();
$_cache = new SystemCache($_db->getPDO());
$_controller = new SystemController($_db, $_cache);
// ?refresh=1 force-busts all cached sections
if (isset($_GET['refresh']) && $_GET['refresh'] === '1') {
$_controller->invalidateAll();
}
// ── Status / PHP / Disk data ──────────────────────────────────────────────────
$statusData = $_controller->getStatusData();
$checks = $statusData['checks'];
$statusCached = $statusData['cached'];
$statusCacheAge = $statusData['cacheAge'];
$phpInfo = $_controller->getPhpInfo();
$diskInfo = $_controller->getDiskInfo();
$diskTotal = $diskInfo['total'];
$diskFree = $diskInfo['free'];
$diskUsed = $diskInfo['used'];
$diskPct = $diskInfo['pct'];
$diskColor = SystemController::diskColor($diskPct);
// ── Active tab + line count ───────────────────────────────────────────────────
$activeTab = $_GET['tab'] ?? 'nginx_access';
if ($activeTab === 'status') {
$activeTab = 'nginx_access'; // legacy redirect
} elseif ($activeTab !== 'nginx_config' && !array_key_exists($activeTab, SystemController::LOG_FILES)) {
$activeTab = 'nginx_access';
}
$selectedN = isset($_GET['n']) ? (int) $_GET['n'] : 100;
if (!in_array($selectedN, SystemController::ALLOWED_LINES, true)) {
$selectedN = 100;
}
// ── Tab content data ──────────────────────────────────────────────────────────
$logLines = null;
$logError = null;
$logFileMeta = null;
$nginxConfigLines = null;
$nginxConfigSource = null;
$nginxConfigError = null;
$nginxConfigMeta = null;
if ($activeTab === 'nginx_config') {
$nginxData = $_controller->getNginxConfigData();
$nginxConfigLines = $nginxData['lines'];
$nginxConfigSource = $nginxData['source'];
$nginxConfigMeta = $nginxData['meta'];
$nginxConfigError = $nginxData['error'];
} else {
$logData = $_controller->getLogData($activeTab, $selectedN);
$logLines = $logData['lines'];
$logError = $logData['error'];
$logFileMeta = $logData['meta'];
}
$isAdmin = true; $bodyClass = 'admin-body';
$extraCss = ['/assets/css/system.css'];
// HTMX loaded once in footer; status collapse + copy via inline JS
require_once APP_ROOT . '/templates/head.php';
// Restore collapsed state from cookie
$collapsed = $_COOKIE['sys_collapsed'] ?? null;
$statusInitiallyCollapsed = $collapsed === '1';
?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<main id="main-content">
<h1>Système</h1>
<p class="sys-refresh-note">
Affiché le <?= date('d/m/Y à H:i:s') ?> —
<a href="?tab=<?= htmlspecialchars($activeTab) ?>&amp;n=<?= $selectedN ?>">Rafraîchir</a> —
<a href="?tab=<?= htmlspecialchars($activeTab) ?>&amp;n=<?= $selectedN ?>&amp;refresh=1">Forcer actualisation</a>
</p>
<!-- ════════════════════════════════════════════════════════════════════
STATUS SECTION — always visible above tabs
════════════════════════════════════════════════════════════════════ -->
<section class="sys-status-section" aria-label="Statut du système">
<div class="sys-status-header">
<h2 class="srv-section-title srv-section-title--compact">Statut
<?php if ($statusCached && $statusCacheAge !== null): ?>
<span class="sys-cache-badge sys-cache-badge--hit" title="Données en cache">
⚡ Cache — il y a <?= $statusCacheAge ?>s
</span>
<?php else: ?>
<span class="sys-cache-badge sys-cache-badge--miss" title="Données fraîches">
⟳ Actualisé
</span>
<?php endif; ?>
</h2>
<button id="sys-status-toggle" class="sys-status-toggle"
aria-expanded="<?= $statusInitiallyCollapsed ? 'false' : 'true' ?>" aria-controls="sys-status-body"
type="button"
onclick="var b=document.getElementById('sys-status-body');var c=b.hidden;b.hidden=!c;this.setAttribute('aria-expanded',c);this.textContent=c?'▲ Réduire':'▼ Développer';document.cookie='sys_collapsed='+(!c)+';path=/;max-age=31536000';return false">
<?= $statusInitiallyCollapsed ? '▼ Développer' : '▲ Réduire' ?>
</button>
</div>
<div id="sys-status-body"<?= $statusInitiallyCollapsed ? ' hidden' : '' ?>>
<div class="srv-grid">
<?php foreach ($checks as $check): ?>
<?php $st = $check['status'] ?? 'unknown'; ?>
<div class="srv-card">
<div class="srv-card__header">
<span class="srv-card__name"><?= htmlspecialchars($check['label']) ?></span>
<span class="<?= SystemController::statusClass($st) ?>"><?= SystemController::statusLabel($st) ?></span>
</div>
<?php if (!empty($check['detail'])): ?>
<div class="srv-card__detail"><?= htmlspecialchars($check['detail']) ?></div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<div class="sys-status-meta">
<div>
<h3 class="srv-section-title srv-section-title--sub">Environnement PHP</h3>
<div class="php-grid php-grid--flush">
<?php foreach ($phpInfo as $key => $val): ?>
<div class="php-item">
<div class="php-item__key"><?= htmlspecialchars($key) ?></div>
<div class="php-item__val"><?= htmlspecialchars($val) ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
<div>
<h3 class="srv-section-title srv-section-title--sub">Espace disque</h3>
<div class="disk-bar-wrap">
<div class="disk-bar" style="--disk-pct:<?= $diskPct ?>%;--disk-color:<?= $diskColor ?>"></div>
</div>
<div class="disk-stats">
<span><?= SystemController::humanBytes($diskUsed) ?> utilisé (<?= $diskPct ?>%)</span>
<span><?= SystemController::humanBytes($diskFree) ?> libre / <?= SystemController::humanBytes($diskTotal) ?></span>
</div>
</div>
</div>
</div>
</section>
<!-- ── Tab bar ─────────────────────────────────────────────────────── -->
<nav class="sys-tabs" aria-label="Journaux et configuration">
<?php foreach (SystemController::LOG_FILES as $key => $def): ?>
<a href="?tab=<?= htmlspecialchars($key) ?>&amp;n=<?= $selectedN ?>"
class="sys-tab <?= $activeTab === $key ? 'active' : '' ?>"
hx-get="/admin/system-fragment.php?tab=<?= htmlspecialchars($key) ?>&amp;n=<?= $selectedN ?>"
hx-target="#sys-tab-panel"
hx-push-url="?tab=<?= htmlspecialchars($key) ?>&amp;n=<?= $selectedN ?>"
hx-swap="innerHTML"
hx-indicator="#sys-tab-panel"
data-tab="<?= htmlspecialchars($key) ?>"
<?= $activeTab === $key ? 'aria-current="page"' : '' ?>>
<?= htmlspecialchars($def['label']) ?>
</a>
<?php endforeach; ?>
<a href="?tab=nginx_config"
class="sys-tab <?= $activeTab === 'nginx_config' ? 'active' : '' ?>"
hx-get="/admin/system-fragment.php?tab=nginx_config"
hx-target="#sys-tab-panel"
hx-push-url="?tab=nginx_config"
hx-swap="innerHTML"
hx-indicator="#sys-tab-panel"
data-tab="nginx_config"
<?= $activeTab === 'nginx_config' ? 'aria-current="page"' : '' ?>>nginx — config</a>
</nav>
<!-- Tab panel — content swapped by HTMX -->
<div id="sys-tab-panel">
<?php if ($activeTab === 'nginx_config'): ?>
<!-- ════════════════════════════════════════════════════════════════════
NGINX CONFIG PANEL
════════════════════════════════════════════════════════════════════ -->
<?php if ($nginxConfigMeta): ?>
<div class="log-meta">
<span data-label="Fichier"><?= htmlspecialchars($nginxConfigMeta['path']) ?></span>
<span data-label="Taille"><?= $nginxConfigMeta['size'] ?></span>
<span data-label="Modifié"><?= $nginxConfigMeta['mtime'] ?></span>
<?php if ($nginxConfigSource === 'live'): ?>
<span class="nginx-source-badge nginx-source-badge--live">● Config déployée</span>
<?php else: ?>
<span class="nginx-source-badge nginx-source-badge--local">⚠ Référence locale (config live inaccessible)</span>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($nginxConfigError !== null): ?>
<div class="log-unavailable">
<strong>Configuration nginx non disponible</strong>
<div class="log-unavail-path"><?= htmlspecialchars($nginxConfigError) ?></div>
<?php if (php_sapi_name() === 'cli-server'): ?>
<div class="log-unavail-dev">
En développement, <code>/etc/nginx/sites-available/posterg</code> n'existe pas.
La config de référence se trouve dans <code>nginx/posterg.conf</code>.
</div>
<?php endif; ?>
</div>
<?php elseif (empty($nginxConfigLines)): ?>
<div class="log-empty">Le fichier de configuration est vide.</div>
<?php else: ?>
<div class="log-output" id="log-output" role="region" aria-label="Configuration nginx">
<button class="log-copy-btn" id="log-copy-btn" type="button" title="Copier la configuration"
onclick="copyLogContent(this);return false">
Copier
</button>
<?php foreach ($nginxConfigLines as $i => $line): ?>
<span class="log-line <?= SystemController::nginxLineClass($line) ?>"
data-n="<?= $i + 1 ?>"><?= htmlspecialchars($line, ENT_QUOTES | ENT_SUBSTITUTE) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php else: ?>
<!-- ════════════════════════════════════════════════════════════════════
LOG PANEL
════════════════════════════════════════════════════════════════════ -->
<!-- Lines selector (submits via JS on change; no button needed) -->
<div class="log-toolbar">
<label for="lines-select">Afficher</label>
<form id="lines-form" hx-get="/admin/system-fragment.php"
hx-target="#sys-tab-panel"
hx-swap="innerHTML"
hx-indicator="#sys-tab-panel"
hx-trigger="change"
hx-vals='{"tab":"<?= htmlspecialchars($activeTab) ?>"}'>
<label for="lines-select">Afficher</label>
<select id="lines-select" name="n" aria-label="Nombre de lignes">
<?php foreach (SystemController::ALLOWED_LINES as $n): ?>
<option value="<?= $n ?>" <?= $n === $selectedN ? 'selected' : '' ?>>
<?= $n ?> dernières lignes
</option>
<?php endforeach; ?>
</select>
</form>
<?php if ($logLines !== null && count($logLines) > 0): ?>
<span class="log-count-badge"><?= count($logLines) ?> ligne(s)</span>
<?php endif; ?>
</div>
<!-- File metadata -->
<?php if ($logFileMeta): ?>
<div class="log-meta">
<span data-label="Fichier"><?= htmlspecialchars(SystemController::LOG_FILES[$activeTab]['path']) ?></span>
<span data-label="Taille"><?= $logFileMeta['size'] ?></span>
<span data-label="Modifié"><?= $logFileMeta['mtime'] ?></span>
</div>
<?php endif; ?>
<!-- Log output -->
<?php if ($logError !== null): ?>
<div class="log-unavailable">
<strong>Journaux non disponibles</strong>
<div class="log-unavail-path"><?= $logError ?></div>
<?php if (php_sapi_name() === 'cli-server'): ?>
<div class="log-unavail-dev">
En environnement de développement, les logs nginx ne sont pas disponibles.
Cette page est pleinement fonctionnelle sur le serveur de production.
</div>
<?php endif; ?>
</div>
<?php elseif (empty($logLines)): ?>
<div class="log-empty">Le fichier journal est vide.</div>
<?php else: ?>
<div class="log-output" id="log-output" role="log" aria-live="off" aria-label="Contenu du journal">
<button class="log-copy-btn" id="log-copy-btn" type="button" title="Copier le contenu"
onclick="copyLogContent(this);return false">
Copier
</button>
<?php foreach ($logLines as $i => $line): ?>
<span class="log-line <?= SystemController::logLineClass($line) ?>"
data-n="<?= count($logLines) - $i ?>"><?= htmlspecialchars($line, ENT_QUOTES | ENT_SUBSTITUTE) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php endif; ?>
</div><!-- #sys-tab-panel -->
</main>
<script>
function copyLogContent(btn) {
var logOut = document.querySelector('#log-output');
if (!logOut) return;
var text = Array.from(logOut.querySelectorAll('.log-line'))
.map(function(el){ return el.textContent; }).join('\n');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function(){
btn.textContent = '\u2713 Copi\u00e9';
btn.classList.add('copied');
setTimeout(function(){ btn.textContent = 'Copier'; btn.classList.remove('copied'); }, 2000);
});
} else {
fallbackCopy(text, btn);
}
}
function fallbackCopy(text, btn) {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;opacity:0';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); btn.textContent = '\u2713 Copi\u00e9'; btn.classList.add('copied');
setTimeout(function(){ btn.textContent = 'Copier'; btn.classList.remove('copied'); }, 2000);
} catch(e) {}
document.body.removeChild(ta);
}
// Update active tab class after each HTMX swap on #sys-tab-panel
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target && evt.detail.target.id === 'sys-tab-panel') {
var rc = evt.detail.requestConfig;
var tab = null;
// Tab clicks carry ?tab=… in the path
var qIdx = rc.path.indexOf('?');
if (qIdx !== -1) {
tab = new URLSearchParams(rc.path.substring(qIdx + 1)).get('tab');
}
// Line-count form sends tab via hx-vals in parameters
if (!tab && rc.parameters && rc.parameters.tab) {
tab = rc.parameters.tab;
}
if (tab) {
document.querySelectorAll('.sys-tabs .sys-tab').forEach(function(a) {
var isActive = a.getAttribute('data-tab') === tab;
a.classList.toggle('active', isActive);
if (isActive) a.setAttribute('aria-current', 'page');
else a.removeAttribute('aria-current');
});
}
}
});
</script>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

91
app/public/admin/tags.php Normal file
View File

@@ -0,0 +1,91 @@
<?php
require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
require_once __DIR__ . '/../../src/Database.php';
$pageTitle = "Gestion des mots-clés";
try {
$db = new Database();
$tags = $db->getAllTagsWithCount();
} catch (Exception $e) {
die("Erreur : " . htmlspecialchars($e->getMessage()));
}
// Flash messages are consumed by the flash-messages partial below.
?>
<?php $isAdmin = true; $bodyClass = 'admin-body'; require_once APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<main id="main-content">
<h1>Mots-clés (<?= count($tags) ?>)</h1>
<table>
<thead>
<tr>
<th scope="col">Nom</th>
<th scope="col">TFE associés</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($tags as $tag): ?>
<tr>
<td><?= htmlspecialchars($tag['name']) ?></td>
<td class="admin-tags-count"><?= (int)$tag['thesis_count'] ?></td>
<td>
<!-- Rename -->
<form method="post" action="actions/tag.php" class="admin-inline-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="rename">
<input type="hidden" name="tag_id" value="<?= (int)$tag['id'] ?>">
<input class="admin-input--inline" type="text" name="new_name"
value="<?= htmlspecialchars($tag['name']) ?>" required>
<button type="submit" class="admin-btn admin-btn--sm">Renommer</button>
</form>
<!-- Merge into another tag -->
<form method="post" action="actions/tag.php" class="admin-inline-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="merge">
<input type="hidden" name="source_id" value="<?= (int)$tag['id'] ?>">
<select name="target_id" class="admin-select--inline" required>
<option value="">— Fusionner dans… —</option>
<?php foreach ($tags as $other): ?>
<?php if ($other['id'] !== $tag['id']): ?>
<option value="<?= (int)$other['id'] ?>"><?= htmlspecialchars($other['name']) ?></option>
<?php endif; ?>
<?php endforeach; ?>
</select>
<button type="submit" class="admin-btn admin-btn--sm admin-btn--warning"
onclick="return confirm('Fusionner ce tag dans la cible ? Le tag source sera supprimé.')">
Fusionner
</button>
</form>
<!-- Delete -->
<form method="post" action="actions/tag.php" class="admin-inline-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="tag_id" value="<?= (int)$tag['id'] ?>">
<button type="submit" class="admin-btn admin-btn--sm admin-btn--danger"
onclick="return confirm('Supprimer le tag « <?= htmlspecialchars(addslashes($tag['name'])) ?> » ? Cette action est irréversible.')">
Supprimer
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</main>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

195
app/public/admin/thanks.php Normal file
View File

@@ -0,0 +1,195 @@
<?php
// Bootstrap application
require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
// Configure error reporting
ini_set('display_errors', 0);
ini_set('log_errors', 1);
ini_set('error_log', 'error.log');
$studentMode = isset($_GET['mode']) && $_GET['mode'] === 'student';
if (!$studentMode) {
AdminAuth::requireLogin();
}
require_once __DIR__ . '/../../src/Database.php';
// Security: Validate thesis ID parameter
$thesisId = null;
$thesis = null;
$files = [];
$error = null;
if (isset($_GET['id'])) {
$thesisId = filter_var($_GET['id'], FILTER_VALIDATE_INT);
if ($thesisId !== false && $thesisId > 0) {
try {
$db = new Database();
// Get thesis data
$thesis = $db->getThesis($thesisId);
if (!$thesis) {
$error = "TFE non trouvé.";
} else {
$files = $db->getThesisFiles($thesisId);
}
} catch (Exception $e) {
error_log("Error loading thesis: " . $e->getMessage());
$error = "Erreur lors de la lecture des données.";
}
} else {
error_log("Invalid thesis ID: " . $_GET['id']);
$error = "Identifiant invalide.";
}
} else {
$error = "Aucun identifiant spécifié.";
}
// Helper function to format file size
function formatFileSize($bytes) {
if ($bytes >= 1073741824) {
return number_format($bytes / 1073741824, 2) . ' GB';
} elseif ($bytes >= 1048576) {
return number_format($bytes / 1048576, 2) . ' MB';
} elseif ($bytes >= 1024) {
return number_format($bytes / 1024, 2) . ' KB';
} else {
return $bytes . ' bytes';
}
}
// Set page title for header
$pageTitle = "Récapitulatif TFE";
?>
<?php
$isAdmin = true;
if ($studentMode) {
$bodyClass = 'admin-body student-body';
require_once APP_ROOT . '/templates/head.php';
} else {
$bodyClass = 'admin-body';
require_once APP_ROOT . '/templates/head.php';
include APP_ROOT . '/templates/header.php';
}
?>
<main id="main-content">
<?php if ($studentMode): ?>
<!-- ═══════════════════ STUDENT MODE: Thank you page ═══════════════════ -->
<div class="thanks-student-page">
<?php if ($error): ?>
<div class="thanks-error">
<h1>⚠ Oups…</h1>
<p><?= htmlspecialchars($error) ?></p>
<a href="/admin/add.php?mode=student" class="btn-new-form">← Retour au formulaire</a>
</div>
<?php elseif ($thesis): ?>
<div class="thanks-success">
<h1>Merci 🎉</h1>
<p class="thanks-message">
Ton TFE <strong><?= htmlspecialchars($thesis['title']) ?></strong> a bien été soumis.
</p>
<a href="/admin/add.php?mode=student" class="btn-new-form">+ Ajouter un nouveau TFE</a>
</div>
<?php else: ?>
<div class="thanks-error">
<h1>Erreur</h1>
<p>Aucune donnée à afficher.</p>
<a href="/admin/add.php?mode=student" class="btn-new-form">← Retour au formulaire</a>
</div>
<?php endif; ?>
</div>
<?php else: ?>
<!-- ═══════════════════ ADMIN MODE: Recap page ═══════════════════ -->
<h1>Récapitulatif TFE</h1>
<?php if ($error): ?>
<p class="toast" role="alert" data-type="error">⚠ <?= htmlspecialchars($error) ?></p>
<p><a href="/admin/add.php" class="admin-btn-secondary">Retour au formulaire</a></p>
<?php elseif ($thesis): ?>
<section>
<h2>Informations de base</h2>
<dl>
<dt>Identifiant</dt><dd><?= htmlspecialchars($thesis['identifier']) ?></dd>
<dt>Titre</dt><dd><?= htmlspecialchars($thesis['title']) ?></dd>
<?php if ($thesis['subtitle']): ?>
<dt>Sous-titre</dt><dd><?= htmlspecialchars($thesis['subtitle']) ?></dd>
<?php endif; ?>
<dt>Auteur·ice(s)</dt><dd><?= htmlspecialchars($thesis['authors']) ?></dd>
<dt>Année</dt><dd><?= htmlspecialchars($thesis['year']) ?></dd>
</dl>
</section>
<section>
<h2>Détails académiques</h2>
<dl>
<dt>Orientation</dt><dd><?= htmlspecialchars($thesis['orientation'] ?? '') ?></dd>
<dt>Atelier pratique</dt><dd><?= htmlspecialchars($thesis['ap_program'] ?? '') ?></dd>
<dt>Finalité</dt><dd><?= htmlspecialchars($thesis['finality_type'] ?? '') ?></dd>
<?php if ($thesis['supervisors']): ?>
<dt>Promoteur·ice(s)</dt><dd><?= htmlspecialchars($thesis['supervisors']) ?></dd>
<?php endif; ?>
</dl>
</section>
<section>
<h2>Contenu</h2>
<dl>
<?php if ($thesis['languages']): ?>
<dt>Langue(s)</dt><dd><?= htmlspecialchars($thesis['languages']) ?></dd>
<?php endif; ?>
<?php if ($thesis['formats']): ?>
<dt>Format(s)</dt><dd><?= htmlspecialchars($thesis['formats']) ?></dd>
<?php endif; ?>
<?php if ($thesis['keywords']): ?>
<dt>Mots-clés</dt><dd><?= htmlspecialchars($thesis['keywords']) ?></dd>
<?php endif; ?>
<?php if ($thesis['file_size_info']): ?>
<dt>Durée / Taille</dt><dd><?= htmlspecialchars($thesis['file_size_info']) ?></dd>
<?php endif; ?>
<?php if ($thesis['baiu_link']): ?>
<dt>Lien</dt><dd><a href="<?= htmlspecialchars($thesis['baiu_link']) ?>" target="_blank" rel="noopener"><?= htmlspecialchars($thesis['baiu_link']) ?></a></dd>
<?php endif; ?>
</dl>
</section>
<?php if (!empty($files)): ?>
<section>
<h2>Fichiers</h2>
<table>
<thead><tr><th scope="col">Type</th><th scope="col">Fichier</th><th scope="col">Taille</th><th scope="col">Date</th></tr></thead>
<tbody>
<?php foreach ($files as $f): ?>
<tr>
<td><?= htmlspecialchars($f['file_type']) ?></td>
<td><?= htmlspecialchars($f['file_name']) ?></td>
<td><?= formatFileSize($f['file_size']) ?></td>
<td><?= date('d/m/Y H:i', strtotime($f['uploaded_at'])) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</section>
<?php endif; ?>
<div class="admin-action-bar">
<a href="/admin/edit.php?id=<?= $thesisId ?>" class="admin-btn">Modifier</a>
<a href="/admin/add.php" class="admin-btn-secondary">Ajouter un autre TFE</a>
<a href="/admin/" class="admin-btn-secondary">Retour à la liste</a>
</div>
<?php else: ?>
<p class="admin-muted">Aucune donnée à afficher.</p>
<p><a href="/admin/add.php" class="admin-btn-secondary">Retour au formulaire</a></p>
<?php endif; ?>
<?php endif; ?>
</main>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

152
app/public/apropos.php Normal file
View File

@@ -0,0 +1,152 @@
<?php
require_once __DIR__ . '/../bootstrap.php';
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/Parsedown.php';
$currentNav = 'apropos';
define('APROPOS_STATIC_CONTENT', "Ce site POSTERG a été créé pour répertorier et valoriser les mémoires de l'erg École de Recherches Graphique de Bruxelles.\n\nL'objectif est à la fois d'offrir une vitrine aux projets des anciennes étudiantes et de mettre en lumière la diversité des disciplines et des parcours qui façonnent l'histoire de l'école à travers les âges, depuis près de 100 ans.");
/**
* Render a comma-separated list of entries with links.
* Entries joined with comma, last two joined with " & ".
*/
function renderEntries(array $entries): string {
if (empty($entries)) return '';
$parts = [];
foreach ($entries as $e) {
$text = htmlspecialchars($e['text'] ?? '');
$url = $e['url'] ?? '';
if (!empty($url)) {
$parts[] = '<span class="apropos-entry"><a href="' . htmlspecialchars($url) . '" target="_blank" rel="noopener">' . $text . '</a></span>';
} else {
$parts[] = '<span class="apropos-entry">' . $text . '</span>';
}
}
$count = count($parts);
if ($count === 1) return $parts[0];
// Join all but last two with ", ", join last two with " & "
$prefix = implode(', ', array_slice($parts, 0, $count - 2));
$suffix = implode(' & ', array_slice($parts, -2));
return $prefix !== '' ? $prefix . ', ' . $suffix : $suffix;
}
try {
$db = Database::getInstance();
// Intro text from pages table
$aboutPage = $db->getPage('about');
$rawContent = $aboutPage ? $aboutPage['content'] : '';
if (empty(trim($rawContent)) || trim($rawContent) === 'Contenu à venir') {
$rawContent = APROPOS_STATIC_CONTENT;
}
// Contacts and credits from apropos_contents table
$contacts = $db->getAproposContent('contacts');
$credits = $db->getAproposContent('credits');
// Apply defaults if DB returns empty
$contacts = is_array($contacts) && !empty($contacts) ? $contacts : null;
$credits = is_array($credits) && !empty($credits) ? $credits : null;
} catch (Exception $e) {
error_log("Error loading about page: " . $e->getMessage());
$rawContent = APROPOS_STATIC_CONTENT;
$contacts = null;
$credits = null;
}
$pd = new Parsedown();
$pd->setSafeMode(true);
$aboutHtml = $pd->text($rawContent);
$pageTitle = 'À Propos Posterg';
$metaDescription = 'À propos de Posterg, le répertoire des mémoires de fin d\'études de l\'erg École de Recherches Graphiques de Bruxelles.';
$ogTags = [
'type' => 'website',
'title' => $pageTitle,
'description' => $metaDescription,
'url' => 'https://posterg.erg.be/apropos.php',
'site_name' => 'Posterg ERG',
];
$extraCss = ['/assets/css/apropos.css'];
$bodyClass = 'apropos-body';
?>
<?php include APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<main class="apropos-main" id="main-content">
<div class="apropos-layout">
<!-- LEFT: sticky table of contents -->
<nav class="apropos-toc" aria-label="Sections de la page">
<p class="apropos-toc-label">Parties</p>
<ul>
<li><a href="#apropos-intro">À propos</a></li>
<?php if (!empty($contacts)): ?>
<li><a href="#apropos-contacts">Contacts</a></li>
<?php endif; ?>
<?php if (!empty($credits)): ?>
<li><a href="#apropos-credits">Crédits</a></li>
<?php endif; ?>
</ul>
<div class="apropos-toc-erg">
<a href="https://erg.be" target="_blank" rel="noopener">
Site de l'erg ↗
</a>
</div>
</nav>
<!-- MIDDLE: main prose + sections -->
<div class="apropos-content">
<!-- Intro text from DB -->
<section class="apropos-section" id="apropos-intro">
<div class="prose">
<?= $aboutHtml ?>
</div>
</section>
<?php if (!empty($contacts)): ?>
<!-- Contacts section -->
<section class="apropos-section" id="apropos-contacts">
<h2 class="apropos-section-title">Contacts</h2>
<div class="apropos-contacts-grid">
<?php foreach ($contacts as $group): ?>
<address class="apropos-contact-card">
<?= renderEntries($group['entries'] ?? []) ?>
<?php if (!empty($group['role'])): ?>
<span><?= htmlspecialchars($group['role']) ?></span>
<?php endif; ?>
<?php
// Show the email from the first entry (or any that has one on separate line)
$emails = array_filter(array_column($group['entries'] ?? [], 'email'), fn($e) => !empty($e));
foreach ($emails as $email):
?>
<a href="mailto:<?= htmlspecialchars($email) ?>"><?= htmlspecialchars($email) ?></a>
<?php endforeach; ?>
</address>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
<?php if (!empty($credits)): ?>
<!-- Credits section -->
<section class="apropos-section" id="apropos-credits">
<h2 class="apropos-section-title">Crédits</h2>
<dl class="apropos-credits-list">
<?php foreach ($credits as $group): ?>
<div class="apropos-credit-row">
<dt><?= htmlspecialchars($group['label']) ?></dt>
<dd><?= renderEntries($group['entries'] ?? []) ?></dd>
</div>
<?php endforeach; ?>
</dl>
</section>
<?php endif; ?>
</div>
</div>
</main>
<?php include APP_ROOT . '/templates/footer.php'; ?>

View File

@@ -0,0 +1,21 @@
# CSS Architecture
## File Structure
- **variables.css** — all CSS custom properties (single source of truth for every color/token)
- **common.css** — reset, header/nav, search bar, accessibility utilities (loaded on all pages)
- **main.css** — home page
- **search.css** — search/directory page
- **tfe.css** — individual thesis page
- **apropos.css** — about + licence pages
- **system.css** — admin system dashboard
- **admin.css** — admin section (loaded alongside `common.css` on every admin page)
- **modern-normalize.min.css** — third-party reset (minified, do not edit)
## Rules
- Every color value lives in `variables.css` as a CSS custom property.
- No hardcoded hex, rgb(), or rgba() in any other file.
- All files `@import url("./variables.css")` at the top.
- Admin and public share the same token names — no separate admin theme.
- No dark-mode media query. System page uses the same light tokens as the rest of the admin section.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,350 @@
/* ============================================================
À PROPOS PAGE (apropos.php)
============================================================ */
@import url("./variables.css");
/* ------------------------------------------------------------------ */
/* Page shell */
/* ------------------------------------------------------------------ */
.apropos-body {
display: flex;
flex-direction: column;
min-height: 100vh;
background: var(--bg-primary);
}
.apropos-main {
flex: 1;
padding: var(--space-xl) var(--space-l) var(--space-2xl);
}
/* ------------------------------------------------------------------ */
/* Two-column layout: sticky TOC nav | content */
/* ------------------------------------------------------------------ */
.apropos-layout {
display: grid;
grid-template-columns: 180px 1fr;
gap: var(--space-2xl);
max-width: 860px;
margin: 0 auto;
align-items: start;
}
/* ------------------------------------------------------------------ */
/* Left — sticky table of contents */
/* ------------------------------------------------------------------ */
.apropos-toc {
position: sticky;
top: var(--space-l);
}
.apropos-toc-label {
font-family: var(--font-body);
font-size: var(--step--2);
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-tertiary);
margin: 0 0 var(--space-2xs) 0;
}
.apropos-toc ul {
list-style: none;
margin: 0 0 var(--space-m) 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-3xs);
}
.apropos-toc ul a {
font-family: var(--font-body);
font-size: var(--step--1);
color: var(--text-secondary);
text-decoration: none;
display: block;
padding: var(--space-3xs) 0;
transition: color 0.15s;
border-left: 2px solid transparent;
padding-left: var(--space-2xs);
}
.apropos-toc ul a:hover {
color: var(--text-primary);
border-left-color: var(--accent-primary);
}
.apropos-toc-erg {
padding-top: var(--space-s);
border-top: 1px solid var(--border-primary);
}
.apropos-toc-erg a {
font-size: var(--step--2);
color: var(--accent-primary);
text-decoration: none;
transition: opacity 0.15s;
}
.apropos-toc-erg a:hover {
opacity: 0.75;
}
/* ------------------------------------------------------------------ */
/* Right — main content area */
/* ------------------------------------------------------------------ */
.apropos-content {
display: flex;
flex-direction: column;
gap: 0;
}
.apropos-section {
padding-bottom: var(--space-xl);
border-bottom: 1px solid var(--border-primary);
margin-bottom: var(--space-xl);
}
.apropos-section:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
/* ------------------------------------------------------------------ */
/* Section titles */
/* ------------------------------------------------------------------ */
.apropos-section-title {
font-family: var(--font-display);
font-size: var(--step-3);
font-weight: 400;
color: var(--text-primary);
margin: 0 0 var(--space-m) 0;
line-height: 1.1;
}
/* ------------------------------------------------------------------ */
/* Intro prose — Markdown-rendered content */
/* ------------------------------------------------------------------ */
.prose {
font-family: var(--font-body);
font-size: var(--step-0);
line-height: 1.6;
color: var(--text-primary);
font-weight: 400;
}
.prose p {
margin: 0 0 1em 0;
}
.prose p:last-child {
margin-bottom: 0;
}
.prose h1,
.prose h2,
.prose h3 {
font-family: var(--font-display);
font-weight: 400;
margin: 1.5em 0 0.5em 0;
}
.prose h1 { font-size: var(--step-3); }
.prose h2 { font-size: var(--step-2); }
.prose h3 { font-size: var(--step-1); }
.prose a {
color: var(--accent-primary);
text-decoration: underline;
text-underline-offset: 2px;
}
.prose ul,
.prose ol {
padding-left: var(--space-m);
margin-bottom: var(--space-s);
}
.prose li {
margin-bottom: 0.3em;
}
.prose strong { font-weight: 700; }
.prose em { font-style: italic; }
.prose code {
font-family: "Courier New", Courier, monospace;
font-size: 0.88em;
background: var(--bg-tertiary);
padding: 0.1em 0.3em;
border-radius: 2px;
}
/* ------------------------------------------------------------------ */
/* Contacts grid */
/* ------------------------------------------------------------------ */
.apropos-contacts-grid {
display: flex;
flex-direction: column;
gap: 0;
}
.apropos-contact-card {
font-style: normal;
padding: var(--space-s) 0;
border-bottom: 1px solid var(--border-primary);
}
.apropos-contact-card:first-child {
border-top: 1px solid var(--border-primary);
}
.apropos-contact-card strong {
display: block;
font-size: var(--step-0);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-3xs);
}
.apropos-contact-card span {
display: block;
font-size: var(--step--1);
color: var(--text-secondary);
line-height: 1.4;
margin-bottom: var(--space-3xs);
}
.apropos-contact-card a {
font-size: var(--step--1);
color: var(--accent-primary);
text-decoration: underline;
text-underline-offset: 2px;
transition: opacity 0.15s;
}
.apropos-contact-card a:hover {
opacity: 0.75;
}
/* ------------------------------------------------------------------ */
/* Credits list */
/* ------------------------------------------------------------------ */
.apropos-credits-list {
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0;
}
.apropos-credit-row {
display: grid;
grid-template-columns: 1fr 1.6fr;
gap: var(--space-s);
padding: var(--space-s) 0;
border-bottom: 1px solid var(--border-primary);
align-items: baseline;
}
.apropos-credit-row:first-child {
border-top: 1px solid var(--border-primary);
}
.apropos-credits-list dt {
font-size: var(--step--2);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.apropos-credits-list dd {
font-size: var(--step--1);
color: var(--text-primary);
margin: 0;
line-height: 1.5;
}
/* ------------------------------------------------------------------ */
/* Single-column layout — used by licence.php (no sidebar) */
/* ------------------------------------------------------------------ */
.apropos-single {
max-width: 720px;
}
/* ------------------------------------------------------------------ */
/* Responsive */
/* ------------------------------------------------------------------ */
@media (max-width: 900px) {
.apropos-layout {
grid-template-columns: 1fr;
gap: var(--space-l);
}
.apropos-toc {
position: static;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-s);
padding-bottom: var(--space-s);
border-bottom: 1px solid var(--border-primary);
}
.apropos-toc-label {
margin: 0;
}
.apropos-toc ul {
flex-direction: row;
margin: 0;
gap: var(--space-xs);
}
.apropos-toc ul a {
border-left: none;
padding-left: 0;
}
.apropos-toc-erg {
border-top: none;
padding-top: 0;
margin-left: auto;
}
.prose {
font-size: var(--step-0);
}
.apropos-section-title {
font-size: var(--step-2);
}
}
@media (max-width: 600px) {
.apropos-main {
padding: var(--space-m) var(--space-s) var(--space-xl);
}
.prose {
font-size: var(--step-0);
}
.apropos-credit-row {
grid-template-columns: 1fr;
gap: var(--space-3xs);
}
}

View File

@@ -0,0 +1,263 @@
@import url("./variables.css");
@font-face {
font-family: "Ductus";
src: url("../fonts/DuctusRegular.otf") format("opentype");
font-style: normal;
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: "BBBDMSans";
src: url("../fonts/BBBDMSans-Light.otf") format("opentype");
font-style: normal;
font-weight: 300;
font-display: swap;
}
@font-face {
font-family: "BBBDMSans";
src: url("../fonts/BBBDMSans-Regular.otf") format("opentype");
font-style: normal;
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: "BBBDMSans";
src: url("../fonts/BBBDMSans-Medium.otf") format("opentype");
font-style: normal;
font-weight: 500;
font-display: swap;
}
@font-face {
font-family: "BBBDMSans";
src: url("../fonts/BBBDMSans-Bold.otf") format("opentype");
font-style: normal;
font-weight: 700;
font-display: swap;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
}
body {
font-family: var(--font-body);
background: var(--bg-primary);
color: var(--text-primary);
}
a {
color: inherit;
text-decoration: none;
}
a:hover {
text-decoration: none;
}
/* ============================================================
HEADER / NAV BAR (public pages)
============================================================ */
body > header {
flex-shrink: 0;
background: linear-gradient(
180deg,
var(--gradient-1) 0%,
var(--gradient-2) 33%,
var(--gradient-3) 66%,
var(--gradient-4) 100%
);
}
body > header nav {
padding: var(--space-s) var(--space-s);
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-logo {
font-family: var(--font-display);
font-size: var(--step-2);
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent-foreground);
text-decoration: none;
text-shadow:
0 0 16px var(--header-shadow-strong),
0 0 32px var(--header-shadow-soft);
}
.nav-left {
display: flex;
align-items: center;
gap: var(--space-l);
}
.nav-left-links,
.nav-right-links {
display: flex;
gap: var(--space-l);
align-items: center;
list-style: none;
margin: 0;
padding: 0;
}
body > header nav > a {
font-family: var(--font-display);
font-size: var(--step-0);
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent-foreground);
text-decoration: none;
}
body > header nav > ul {
display: flex;
gap: var(--space-l);
align-items: center;
list-style: none;
margin: 0;
padding: 0;
}
body > header nav ul a {
font-size: var(--step--1);
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent-foreground);
text-decoration: none;
transition: opacity 0.15s;
}
/* Subtle shadow on all header text to improve legibility against the gradient */
body > header nav > a,
body > header nav ul a {
text-shadow:
0 0 16px var(--header-shadow-strong),
0 0 32px var(--header-shadow-soft);
}
body > header nav ul a:hover {
opacity: 1;
}
body > header nav ul a[aria-current="page"] {
opacity: 1;
border-bottom: 1px solid var(--header-nav-active-border);
padding-bottom: 1px;
}
/* ============================================================
SEARCH BAR (shared)
============================================================ */
.header-search-wrap {
padding: 0 0;
background-color: var(--gradient-4);
background: linear-gradient(
180deg,
var(--gradient-4) 0%,
#ffffffee 100%
);
}
.header-search-wrap form[role="search"] {
display: flex;
align-items: center;
gap: var(--space-2xs);
padding: var(--space-3xs) var(--space-s);
border: 1px solid var(--accent-primary);
border-radius: 10px;
background: var(--bg-primary);
width: 100%;
color: var(--accent-primary);
}
.header-search-wrap form[role="search"] svg {
color: var(--text-tertiary);
flex-shrink: 0;
width: 16px;
height: 16px;
stroke: var(--accent-primary);
}
.header-search-wrap form[role="search"] input {
flex: 1;
border: none;
font-size: var(--step--1);
color: var(--text-primary);
background: transparent;
padding: var(--space-3xs) 0;
font-family: inherit;
}
.header-search-wrap form[role="search"] input::placeholder {
color: var(--accent-primary);
}
/* ============================================================
ACCESSIBILITY UTILITIES
============================================================ */
/* Visually-hidden but screen-reader-accessible */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Skip-to-content link (visible only on keyboard focus) */
.skip-link {
position: absolute;
top: -999px;
left: 1rem;
z-index: 9999;
padding: var(--space-2xs) var(--space-s);
background: var(--accent-primary);
color: var(--text-primary);
font-size: var(--step--1);
font-weight: 600;
text-decoration: none;
border-radius: 0 0 4px 4px;
}
.skip-link:focus {
top: 0;
}
/* Consistent keyboard-focus outline for all interactive elements */
:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
/* Respect user motion preferences */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}

View File

@@ -0,0 +1,275 @@
/* ============================================================
HOME PAGE (index.php)
============================================================ */
@import url("./variables.css");
.home-body {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background: var(--bg-primary);
}
/* Cards grid — scrollable main area */
.home-main {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 0;
}
.cards-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-s);
list-style: none;
margin: 0;
padding: var(--space-xs) var(--space-s) var(--space-m);
}
@media (min-width: 1400px) {
.cards-container {
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
}
}
@media (max-width: 768px) {
.cards-container {
grid-template-columns: repeat(2, 1fr);
}
}
/* Each card = list item containing a block <a> link */
.card {
display: flex;
flex-direction: column;
cursor: pointer;
border: 1px solid var(--border-primary);
border-radius: 8px;
background: var(--bg-primary);
overflow: hidden;
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
}
.card > a {
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
flex: 1;
border-radius: inherit;
}
/* Media wrapper: <figure> for real images/video, <div class="card__media--gradient"> for placeholders */
.home-body figure {
width: 100%;
aspect-ratio: 4/3;
overflow: hidden;
background: var(--bg-tertiary);
position: relative;
margin: 0; /* reset <figure> default margin */
border-radius: 7px 7px 0 0;
}
.home-body figure img,
.home-body figure video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.3s ease;
}
.card:hover figure img,
.card:hover figure video {
transform: scale(1.02);
}
.card__media--placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, var(--bg-tertiary), var(--bg-active));
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
font-size: var(--step-3);
}
.card__media--gradient {
width: 100%;
aspect-ratio: 4/3;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-s);
text-align: center;
box-sizing: border-box;
border-radius: 7px 7px 0 0;
background: linear-gradient(
180deg,
rgba(60, 133, 108, 1) 0%,
rgba(96, 236, 180, 1) 33%,
rgba(227, 144, 255, 1) 66%,
rgba(149, 87, 181, 1) 100%
);
}
.card__gradient-author {
color: var(--accent-foreground);
font-size: var(--step--2);
opacity: 0.85;
margin-bottom: 0.25rem;
display: block;
}
.card__gradient-title {
color: var(--accent-foreground);
font-size: var(--step--1);
font-weight: 600;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Section label */
.home-section-label {
font-style: normal;
background: transparent;
color: var(--text-primary);
font-size: var(--step--2);
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: var(--space-s) var(--space-s) var(--space-xs);
border-bottom: 1px solid var(--border-primary);
margin: 0;
}
/* Card caption — <p> directly inside the card <a> link, below the media */
.home-body li > a > p {
padding: var(--space-2xs) var(--space-xs) var(--space-xs);
font-size: var(--step--1);
line-height: 1.35;
color: var(--text-primary);
margin: 0;
font-weight: 400;
border-top: 1px solid var(--border-primary);
}
/* Empty-state message shown when no cards exist */
.cards-empty {
padding: var(--space-l);
color: var(--text-tertiary);
font-size: var(--step--1);
list-style: none;
}
/* Filter info */
.filter-info {
background: var(--accent-muted);
color: var(--accent-secondary);
padding: var(--space-3xs) var(--space-s);
font-size: var(--step--1);
display: flex;
align-items: center;
gap: var(--space-s);
flex-shrink: 0;
}
.clear-filter {
color: var(--accent-secondary);
text-decoration: none;
padding: var(--space-3xs) var(--space-2xs);
background: var(--accent-muted);
border-radius: 3px;
font-size: var(--step--1);
}
.clear-filter:hover {
background: var(--accent-secondary);
}
/* Pagination */
.pagination-wrap {
border-top: 1px solid var(--border-secondary);
background: var(--bg-primary);
flex-shrink: 0;
}
.pagination-wrap ul {
display: flex;
justify-content: center;
align-items: center;
gap: var(--space-2xs);
padding: var(--space-s);
list-style: none;
margin: 0;
}
.pagination-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.75rem;
min-height: 2.75rem;
padding: 0 var(--space-2xs);
border: 1px solid var(--border-secondary);
border-radius: 3px;
color: var(--text-primary);
font-size: var(--step--1);
text-decoration: none;
transition: all 0.15s;
}
.pagination-btn:hover:not(.disabled) {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.pagination-btn.disabled {
opacity: 0.3;
cursor: not-allowed;
pointer-events: none;
}
.pagination-info {
font-size: var(--step--1);
color: var(--text-secondary);
padding: 0 var(--space-2xs);
}
.page-current {
font-weight: 600;
color: var(--text-primary);
}
/* Suppress card hover scale for users who prefer reduced motion */
@media (prefers-reduced-motion: reduce) {
.home-body figure img,
.home-body figure video {
transition: none;
}
.card:hover figure img,
.card:hover figure video {
transform: none;
}
.card__media--gradient {
transition: none;
}
.card {
transition: none;
}
.card:hover {
transform: none;
}
}

View File

@@ -0,0 +1,9 @@
/**
* Minified by jsDelivr using clean-css v5.3.3.
* Original file: /npm/modern-normalize@3.0.1/modern-normalize.css
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
/*! modern-normalize v3.0.1 | MIT License | https://github.com/sindresorhus/modern-normalize */
*,::after,::before{box-sizing:border-box}html{font-family:system-ui,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji';line-height:1.15;-webkit-text-size-adjust:100%;tab-size:4}body{margin:0}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-color:currentcolor}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}legend{padding:0}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}
/*# sourceMappingURL=/sm/d2d8cd206fb9f42f071e97460f3ad9c875edb5e7a4b10f900a83cdf8401c53a9.map */

View File

@@ -0,0 +1,338 @@
/* ============================================================
RÉPERTOIRE / SEARCH PAGE (repertoire.php)
============================================================ */
@import url("./variables.css");
.search-body {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background: var(--bg-primary);
}
.search-main {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
/* ---- 6-column index layout ---- */
.repertoire-index {
display: grid;
grid-template-columns: 0.7fr 1.2fr 1.4fr 0.9fr 1.4fr 1fr;
gap: 0;
padding: 0 var(--space-m);
min-height: 100%;
}
@media (max-width: 1024px) {
.repertoire-index {
grid-template-columns: 1fr 1fr 1fr;
padding: 0 var(--space-s);
min-height: auto;
}
.repertoire-col {
border-right: 1px solid var(--border-secondary);
border-bottom: 1px solid var(--border-primary);
}
.repertoire-col:nth-child(3n) {
border-right: none;
}
}
@media (max-width: 600px) {
.repertoire-index {
grid-template-columns: 1fr;
padding: 0 var(--space-s);
min-height: auto;
}
.repertoire-col {
border-right: none;
border-bottom: 1px solid var(--border-primary);
padding: var(--space-s) 0 var(--space-m);
}
.repertoire-col:last-child {
border-bottom: none;
}
}
.repertoire-col {
padding: var(--space-xs) var(--space-2xs) var(--space-l);
border-right: 1px solid var(--border-secondary);
}
.repertoire-col:last-child {
border-right: none;
}
.repertoire-index section > h2 {
font-size: var(--step--2);
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-secondary);
font-weight: 400;
margin: 0 0 var(--space-2xs) 0;
padding-bottom: var(--space-3xs);
border-bottom: 1px solid var(--border-secondary);
}
/* Strip list chrome inside repertoire columns */
.repertoire-col ul {
list-style: none;
margin: 0;
padding: 0;
}
/* ---- rep-entry: shared base (button + link variants) ---- */
.rep-entry {
display: block;
width: 100%;
text-align: left;
background: none;
border: none;
padding: var(--space-3xs) 0;
margin: 0;
font-family: inherit;
font-size: var(--step-0);
color: var(--text-primary);
line-height: 1.4;
cursor: pointer;
text-decoration: none;
transition: color 0.15s, opacity 0.15s;
}
.rep-entry:hover {
color: var(--accent-primary);
}
/* Link variant (students col) — no underline by default */
.rep-entry--link {
text-decoration: none;
}
/* Selected: accent color */
.rep-entry--selected {
color: var(--accent-primary) !important;
}
/* Faded/disabled: muted, not interactive */
.rep-entry--faded {
opacity: 0.3;
cursor: not-allowed;
pointer-events: none;
}
/* Years column — big bold numbers */
.repertoire-col[data-col="years"] .rep-entry {
font-size: var(--step-3);
font-weight: 700;
line-height: 1.1;
letter-spacing: -0.02em;
padding: var(--space-3xs) 0;
}
/* Empty state in students column */
.rep-empty {
color: var(--text-tertiary);
font-size: var(--step--1);
padding: var(--space-3xs) 0;
}
/* ---- HTMX loading indicator ---- */
.rep-indicator {
display: block;
position: fixed;
top: 0;
left: 0;
right: 0;
height: 2px;
background: var(--accent-primary);
opacity: 0;
transition: opacity 0.15s;
z-index: 100;
pointer-events: none;
}
.rep-indicator.htmx-request {
opacity: 1;
animation: rep-progress 1.2s ease-in-out infinite;
}
@keyframes rep-progress {
0% { transform: scaleX(0); transform-origin: left; }
50% { transform: scaleX(0.7); transform-origin: left; }
100% { transform: scaleX(1); transform-origin: left; }
}
/* ---- Search results view (grid) ---- */
.search-results-header {
display: block;
font-size: var(--step--1);
color: var(--text-secondary);
margin: var(--space-s) var(--space-m) var(--space-2xs);
}
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--space-m);
list-style: none;
margin: 0;
padding: var(--space-2xs) var(--space-m) var(--space-m);
}
.result-card {
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
gap: var(--space-3xs);
}
.result-card__authors {
font-size: var(--step--1);
font-weight: 500;
color: var(--text-primary);
}
.result-card__title {
font-size: var(--step--1);
color: var(--text-secondary);
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.result-card__meta {
font-size: var(--step--2);
color: var(--accent-primary);
font-style: normal;
}
/* Search controls bar */
.search-controls {
display: flex;
align-items: center;
gap: var(--space-s);
padding: var(--space-3xs) var(--space-m);
border-bottom: 1px solid var(--border-secondary);
flex-shrink: 0;
flex-wrap: wrap;
}
.search-filter-label {
display: flex;
align-items: center;
gap: var(--space-3xs);
font-size: var(--step--2);
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
white-space: nowrap;
cursor: default;
}
.search-filter-select {
font-size: var(--step--1);
border: 1px solid var(--border-primary);
border-radius: 3px;
padding: var(--space-3xs) var(--space-2xs);
background: var(--bg-primary);
color: var(--text-primary);
font-family: inherit;
cursor: pointer;
}
.search-filter-select:focus {
border-color: var(--accent-primary);
}
.search-apply-btn {
font-size: var(--step--1);
padding: var(--space-3xs) var(--space-xs);
background: var(--accent-primary);
color: var(--accent-foreground);
border: none;
border-radius: 3px;
cursor: pointer;
font-family: inherit;
transition: background 0.15s;
}
.search-apply-btn:hover {
background: var(--accent-secondary);
}
.search-reset-link {
font-size: var(--step--1);
color: var(--text-secondary);
text-decoration: underline;
cursor: pointer;
}
/* Search results pagination */
.pagination-wrap {
display: flex;
justify-content: center;
align-items: center;
gap: var(--space-2xs);
padding: var(--space-m) 0;
}
.pagination-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.75rem;
min-height: 2.75rem;
padding: 0 var(--space-2xs);
border: 1px solid var(--border-secondary);
border-radius: 3px;
color: var(--text-primary);
font-size: var(--step--1);
text-decoration: none;
transition: all 0.15s;
}
.pagination-btn:hover:not(.disabled) {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.pagination-btn.disabled {
opacity: 0.3;
cursor: not-allowed;
pointer-events: none;
}
.pagination-info {
font-size: var(--step--1);
color: var(--text-secondary);
padding: 0 var(--space-2xs);
}
.search-empty {
padding: var(--space-xl) var(--space-m);
color: var(--text-secondary);
font-size: var(--step-0);
text-align: center;
}
/* Error message */
.search-error {
background: var(--search-error-bg);
border-left: 3px solid var(--search-error-border);
color: var(--search-error-color);
padding: var(--space-2xs) var(--space-s);
font-size: var(--step--1);
margin: var(--space-2xs) var(--space-m);
flex-shrink: 0;
}

View File

@@ -0,0 +1,396 @@
/* ============================================================
SYSTEM PAGE
============================================================ */
@import url("./variables.css");
/* ── System page tabs ──────────────────────────────────────────────────── */
.sys-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border-primary);
margin-bottom: var(--space-m);
}
.sys-tab {
display: inline-block;
padding: var(--space-3xs) var(--space-s);
font-size: var(--step--1);
font-weight: 500;
color: var(--text-secondary);
text-decoration: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color .15s, border-color .15s;
}
.sys-tab:hover {
color: var(--text-primary);
}
.sys-tab.active {
color: var(--accent-primary);
border-bottom-color: var(--accent-primary);
}
/* ── Status section (always-visible panel above tabs) ─────────────────── */
.sys-status-section {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 6px;
padding: var(--space-s) var(--space-m);
margin-bottom: var(--space-m);
}
.sys-status-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-s);
}
.sys-status-toggle {
background: none;
border: 1px solid var(--border-primary);
color: var(--text-secondary);
border-radius: 3px;
font-size: var(--step--2);
font-family: inherit;
padding: var(--space-3xs) var(--space-2xs);
cursor: pointer;
white-space: nowrap;
transition: color .15s, border-color .15s;
}
.sys-status-toggle:hover {
color: var(--text-primary);
border-color: var(--border-secondary);
}
.sys-status-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-m) var(--space-l);
margin-top: var(--space-m);
padding-top: var(--space-m);
border-top: 1px solid var(--border-primary);
}
@media (max-width: 700px) {
.sys-status-meta { grid-template-columns: 1fr; }
}
/* ── Status cards ──────────────────────────────────────────────────────── */
.srv-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--space-s);
margin-bottom: var(--space-l);
}
.srv-card {
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: 5px;
padding: var(--space-s) var(--space-m);
}
.srv-card__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-3xs);
}
.srv-card__name {
font-size: var(--step--1);
text-transform: uppercase;
letter-spacing: .07em;
color: var(--text-secondary);
font-weight: 500;
}
.srv-card__detail {
font-size: var(--step--2);
color: var(--text-tertiary);
margin-top: var(--space-3xs);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
}
.status-ok { color: var(--accent-green); font-weight: 600; font-size: var(--step--1); }
.status-warn { color: var(--warning); font-weight: 600; font-size: var(--step--1); }
.status-err { color: var(--error); font-weight: 600; font-size: var(--step--1); }
.status-unknown { color: var(--text-tertiary); font-weight: 600; font-size: var(--step--1); }
.srv-section-title {
font-size: var(--step--1);
text-transform: uppercase;
letter-spacing: .1em;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-primary);
padding-bottom: var(--space-3xs);
margin: 0 0 var(--space-s);
font-weight: 500;
}
/* Compact variant: no border, no margin — used inside sys-status-header */
.srv-section-title--compact {
margin: 0;
border: none;
padding: 0;
}
/* Sub-section variant: tighter bottom margin — used for PHP/disk sub-headings */
.srv-section-title--sub { margin-bottom: var(--space-xs); }
/* ── PHP info grid ─────────────────────────────────────────────────────── */
.php-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--space-3xs) var(--space-xs);
margin-bottom: var(--space-l);
}
/* Flush variant: no bottom margin — used inside sys-status-meta cell */
.php-grid--flush { margin-bottom: 0; }
.php-item {
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: 4px;
padding: var(--space-3xs) var(--space-xs);
}
.php-item__key {
font-size: var(--step--2);
text-transform: uppercase;
letter-spacing: .06em;
color: var(--text-tertiary);
}
.php-item__val {
font-size: var(--step--1);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
color: var(--text-primary);
margin-top: var(--space-3xs);
}
/* ── Disk bar ──────────────────────────────────────────────────────────── */
.disk-bar-wrap {
background: var(--border-primary);
border-radius: 3px;
height: 6px;
margin-top: var(--space-2xs);
overflow: hidden;
}
.disk-bar {
height: 100%;
border-radius: 3px;
width: var(--disk-pct, 0%);
background: var(--disk-color, var(--accent-green));
transition: width .3s;
}
.disk-stats {
display: flex;
justify-content: space-between;
font-size: var(--step--2);
color: var(--text-secondary);
margin-top: var(--space-3xs);
}
/* ── Tab panel loading state ───────────────────────────────────────────── */
#sys-tab-panel {
min-height: 8rem;
position: relative;
}
#sys-tab-panel.htmx-request {
opacity: 0.4;
pointer-events: none;
transition: opacity 0.1s;
}
#sys-tab-panel.htmx-request::after {
content: '';
position: absolute;
inset: 0;
background: repeating-linear-gradient(
-45deg,
transparent,
transparent 6px,
rgba(0,0,0,0.03) 6px,
rgba(0,0,0,0.03) 12px
);
border-radius: 4px;
animation: sys-panel-shimmer 1s linear infinite;
background-size: 200% 200%;
}
@keyframes sys-panel-shimmer {
0% { background-position: 0 0; }
100% { background-position: 100% 100%; }
}
/* ── Log viewer ────────────────────────────────────────────────────────── */
.log-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2xs);
margin-bottom: var(--space-m);
}
.log-toolbar label {
font-size: var(--step--1);
color: var(--text-secondary);
}
.log-toolbar select {
background: var(--bg-primary);
border: 1px solid var(--border-primary);
color: var(--text-primary);
border-radius: 4px;
padding: var(--space-3xs) var(--space-xs);
font-size: var(--step--1);
font-family: inherit;
}
.log-toolbar select:focus { outline: 2px solid var(--accent-primary); }
.log-meta {
font-size: var(--step--2);
color: var(--text-tertiary);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
margin-bottom: var(--space-xs);
display: flex;
gap: var(--space-m);
flex-wrap: wrap;
}
.log-meta span::before { content: attr(data-label) ": "; opacity: .6; }
.log-unavailable {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 4px;
padding: var(--space-m);
color: var(--text-secondary);
font-size: var(--step--1);
}
.log-unavail-path {
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: var(--step--2);
margin-top: var(--space-3xs);
opacity: .7;
}
.log-unavail-dev {
margin-top: var(--space-2xs);
font-size: var(--step--2);
opacity: .7;
}
.log-empty {
color: var(--text-secondary);
font-size: var(--step--1);
padding: var(--space-s) 0;
}
.log-output {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 4px;
padding: var(--space-s);
overflow-x: auto;
font-family: ui-monospace, "SFMono-Regular", Consolas, "Courier New", monospace;
font-size: var(--step--2);
line-height: 1.55;
max-height: 62vh;
overflow-y: auto;
position: relative;
}
.log-line {
display: block;
white-space: pre;
padding: var(--space-3xs) var(--space-3xs);
border-radius: 2px;
color: var(--text-primary);
}
.log-line + .log-line { border-top: 1px solid var(--border-primary); }
.log-crit { color: var(--sys-syntax-crit); background: var(--error-muted-bg); }
.log-error { color: var(--error); }
.log-warn { color: var(--warning); }
.log-notice { color: var(--sys-syntax-notice); }
.log-line::before {
content: attr(data-n);
display: inline-block;
min-width: 4ch;
margin-right: var(--space-xs);
opacity: .3;
text-align: right;
user-select: none;
}
.log-count-badge {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 3px;
font-size: var(--step--2);
padding: var(--space-3xs) var(--space-2xs);
color: var(--text-secondary);
font-family: ui-monospace, monospace;
}
.log-copy-btn {
position: absolute;
top: var(--space-2xs);
right: var(--space-2xs);
background: var(--bg-primary);
border: 1px solid var(--border-primary);
color: var(--text-secondary);
border-radius: 4px;
font-size: var(--step--2);
padding: var(--space-3xs) var(--space-2xs);
cursor: pointer;
font-family: inherit;
transition: color .15s, border-color .15s;
z-index: 2;
}
.log-copy-btn:hover {
color: var(--text-primary);
border-color: var(--accent-primary);
}
.log-copy-btn.copied {
color: var(--accent-green);
border-color: var(--accent-green);
}
.sys-refresh-note {
font-size: var(--step--2);
color: var(--text-secondary);
margin-bottom: var(--space-m);
}
.sys-refresh-note a {
color: var(--accent-primary);
text-decoration: none;
}
.sys-refresh-note a:hover { text-decoration: underline; }
/* ── Cache freshness badges ────────────────────────────────────────────── */
.sys-cache-badge {
display: inline-block;
font-size: var(--step--2);
font-weight: 400;
font-family: ui-monospace, monospace;
padding: var(--space-3xs) var(--space-3xs);
border-radius: 3px;
margin-left: var(--space-xs);
vertical-align: middle;
line-height: 1.6;
}
.sys-cache-badge--hit {
background: var(--warning-muted-bg);
color: var(--warning);
border: 1px solid var(--warning-muted-border);
}
.sys-cache-badge--miss {
background: var(--success-muted-bg);
color: var(--success);
border: 1px solid var(--success-muted-border);
}
/* ── Nginx config viewer ───────────────────────────────────────────────── */
.nginx-source-badge {
display: inline-block;
font-size: var(--step--2);
font-family: ui-monospace, monospace;
padding: var(--space-3xs) var(--space-2xs);
border-radius: 3px;
margin-left: var(--space-2xs);
vertical-align: middle;
}
.nginx-source-badge--live {
background: var(--success-muted-bg);
color: var(--success);
border: 1px solid var(--success-muted-border);
}
.nginx-source-badge--local {
background: var(--warning-muted-bg);
color: var(--warning);
border: 1px solid var(--warning-muted-border);
}
/* Nginx syntax highlight layers inside .log-output */
.nginx-comment { color: var(--sys-syntax-comment); font-style: italic; }
.nginx-directive { color: var(--sys-syntax-directive); }
.nginx-block { color: var(--sys-syntax-block); font-weight: 600; }
.nginx-value { color: var(--sys-syntax-value); }
.nginx-location { color: var(--sys-syntax-location); }

View File

@@ -0,0 +1,195 @@
/* ============================================================
TFE INDIVIDUAL PAGE (tfe.php)
============================================================ */
@import url("./variables.css");
.tfe-body {
display: flex;
flex-direction: column;
min-height: 100vh;
background: var(--bg-primary);
}
.tfe-main {
flex: 1;
padding: var(--space-l) var(--space-m) var(--space-xl);
}
/* Two-column article layout */
.tfe-layout {
display: grid;
grid-template-columns: 1fr 1.4fr;
gap: var(--space-xl);
width: 100%;
max-width: 1200px;
align-items: start;
}
/* Left column — article header */
.tfe-left {
display: flex;
flex-direction: column;
gap: var(--space-m);
}
/* Author (p) — above title */
.tfe-author {
font-family: var(--font-display);
font-size: var(--step-1);
font-weight: 400;
color: var(--text-primary);
margin: 0;
line-height: 1.3;
}
/* Title (h1) — primary heading, very large */
.tfe-title {
font-family: var(--font-display);
font-size: var(--step-3);
font-weight: 400;
color: var(--text-primary);
margin: 0;
line-height: 1.15;
letter-spacing: -0.01em;
}
/* Metadata description list — target <dl> directly inside article > header */
article dl {
display: flex;
flex-direction: column;
gap: var(--space-3xs);
font-size: var(--step--1);
line-height: 1.4;
margin: 0;
}
/* Each dt/dd pair grouped in a <div> inside <dl> */
article dl > div {
display: flex;
gap: var(--space-3xs);
flex-wrap: wrap;
}
article dl dt {
color: var(--text-primary);
font-weight: 400;
flex-shrink: 0;
}
article dl dd {
color: var(--text-primary);
font-weight: 700;
margin: 0;
}
article dl dd a {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
}
/* Note field: align dt/dd to top, value in italics */
.tfe-meta-note {
align-items: start;
}
.tfe-note-value {
font-style: italic;
}
/* Synopsis paragraph */
.tfe-synopsis-text {
font-size: var(--step--1);
line-height: 1.7;
color: var(--text-primary);
margin: 0;
}
/* Right column — aside (supplementary media) */
.tfe-right {
display: flex;
flex-direction: column;
gap: var(--space-m);
}
/* Each file display unit — target <figure> directly inside <aside> */
aside figure {
overflow: hidden;
margin: 0;
border-radius: 8px;
}
aside figure img {
width: 100%;
height: auto;
display: block;
border-radius: 8px;
}
aside figure embed,
aside figure video {
width: 100%;
display: block;
border: none;
}
aside figure video {
max-height: 500px;
}
aside figure embed {
height: clamp(300px, 80vh, 700px);
}
/* figcaption under media — target <figcaption> inside <aside> */
aside figcaption {
font-size: var(--step--2);
color: var(--text-secondary);
margin: var(--space-3xs) 0 0;
font-style: italic;
}
/* PDF fallback download link */
.tfe-pdf-fallback {
font-size: var(--step--1);
margin: var(--space-3xs) 0 0;
}
.tfe-pdf-fallback a {
color: var(--text-primary);
text-decoration: underline;
text-underline-offset: 2px;
}
/* "Not available" and "no files" notices */
.tfe-restricted,
.tfe-no-files {
font-size: var(--step--1);
color: var(--text-secondary);
font-style: italic;
padding: var(--space-s) 0;
margin: 0;
}
/* Responsive */
@media (max-width: 900px) {
.tfe-layout {
grid-template-columns: 1fr;
gap: var(--space-l);
}
.tfe-title {
font-size: var(--step-2);
}
}
@media (max-width: 600px) {
.tfe-main {
padding: var(--space-m) var(--space-s) var(--space-l);
}
.tfe-title {
font-size: var(--step-1);
}
}

View File

@@ -0,0 +1,121 @@
/* ============================================================
CSS VARIABLES (CUSTOM PROPERTIES)
============================================================ */
/* @link https://utopia.fyi/type/calculator?c=360,18,1.2,1240,20,1.25,5,2,&s=0.75|0.5|0.25,1.5|2|3|4|6,s-l&g=s,l,xl,12 */
:root {
--step--2: clamp(0.7813rem, 0.7736rem + 0.0341vw, 0.8rem);
--step--1: clamp(0.9375rem, 0.9119rem + 0.1136vw, 1rem);
--step-0: clamp(1.125rem, 1.0739rem + 0.2273vw, 1.25rem);
--step-1: clamp(1.35rem, 1.2631rem + 0.3864vw, 1.5625rem);
--step-2: clamp(1.62rem, 1.4837rem + 0.6057vw, 1.9531rem);
--step-3: clamp(1.944rem, 1.7405rem + 0.9044vw, 2.4414rem);
--step-4: clamp(2.3328rem, 2.0387rem + 1.3072vw, 3.0518rem);
--step-5: clamp(2.7994rem, 2.384rem + 1.8461vw, 3.8147rem);
}
/* @link https://utopia.fyi/space/calculator?c=360,18,1.2,1240,20,1.25,5,2,&s=0.75|0.5|0.25,1.5|2|3|4|6,s-l&g=s,l,xl,12 */
:root {
--space-3xs: clamp(0.3125rem, 0.3125rem + 0vw, 0.3125rem);
--space-2xs: clamp(0.5625rem, 0.5369rem + 0.1136vw, 0.625rem);
--space-xs: clamp(0.875rem, 0.8494rem + 0.1136vw, 0.9375rem);
--space-s: clamp(1.125rem, 1.0739rem + 0.2273vw, 1.25rem);
--space-m: clamp(1.6875rem, 1.6108rem + 0.3409vw, 1.875rem);
--space-l: clamp(2.25rem, 2.1477rem + 0.4545vw, 2.5rem);
--space-xl: clamp(3.375rem, 3.2216rem + 0.6818vw, 3.75rem);
--space-2xl: clamp(4.5rem, 4.2955rem + 0.9091vw, 5rem);
--space-3xl: clamp(6.75rem, 6.4432rem + 1.3636vw, 7.5rem);
/* One-up pairs */
--space-3xs-2xs: clamp(0.3125rem, 0.1847rem + 0.5682vw, 0.625rem);
--space-2xs-xs: clamp(0.5625rem, 0.4091rem + 0.6818vw, 0.9375rem);
--space-xs-s: clamp(0.875rem, 0.7216rem + 0.6818vw, 1.25rem);
--space-s-m: clamp(1.125rem, 0.8182rem + 1.3636vw, 1.875rem);
--space-m-l: clamp(1.6875rem, 1.3551rem + 1.4773vw, 2.5rem);
--space-l-xl: clamp(2.25rem, 1.6364rem + 2.7273vw, 3.75rem);
--space-xl-2xl: clamp(3.375rem, 2.7102rem + 2.9545vw, 5rem);
--space-2xl-3xl: clamp(4.5rem, 3.2727rem + 5.4545vw, 7.5rem);
/* Custom pairs */
--space-s-l: clamp(1.125rem, 0.5625rem + 2.5vw, 2.5rem);
}
:root {
/* Fonts */
--font-body: "BBBDMSans", sans-serif;
--font-display: "Ductus", sans-serif;
/* Backgrounds */
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--bg-tertiary: #e8e8e8;
--bg-active: #d0d0d0;
/* Text */
--text-primary: #111111;
--text-secondary: #666666;
--text-tertiary: #999999;
/* Borders */
--border-primary: #dddddd;
--border-secondary: #cccccc;
/* Status */
--success: #5cd69d;
--error: #f25a5a;
--warning: #fbca51;
/* Accent */
--accent-primary: #9557b5;
--accent-secondary: #683d7f;
--accent-foreground: #ffffff;
--accent-muted: rgba(149, 87, 181, 0.12);
--accent-blue: #41adff;
--accent-green: #4caf50;
--accent-yellow: #f39c12;
--accent-red: #f25a5a;
/* Gradient (header) */
--gradient-1: #3c856c;
--gradient-2: #60ecb4;
--gradient-3: #e390ff;
--gradient-4: #9557b5;
/* Header decorative */
--header-gradient-fade: rgba(149, 87, 181, 0);
--header-shadow-strong: rgba(119, 70, 145, 1);
--header-shadow-soft: rgba(119, 70, 145, 0.8);
--header-nav-active-border: rgba(255, 255, 255, 0.6);
/* Search error block */
--search-error-bg: #fff0f0;
--search-error-border: #cc0000;
--search-error-color: #cc0000;
/* System page — log/config syntax highlight */
--sys-syntax-comment: #999999;
--sys-syntax-directive: #1a6fb5;
--sys-syntax-block: #7a2fa0;
--sys-syntax-value: #a05c00;
--sys-syntax-location: #1a7a6b;
--sys-syntax-notice: #3a6ea8;
--sys-syntax-crit: #c0392b;
/* Muted alpha overlays — derived from semantic tokens */
--success-muted-bg: rgba(92, 214, 157, 0.12);
--success-muted-border: rgba(92, 214, 157, 0.35);
--warning-muted-bg: rgba(251, 202, 81, 0.12);
--warning-muted-border: rgba(251, 202, 81, 0.35);
--error-muted-bg: rgba(242, 90, 90, 0.12);
--error-muted-border: rgba(242, 90, 90, 0.35);
--blue-muted-bg: rgba(65, 173, 255, 0.12);
--blue-muted-border: rgba(65, 173, 255, 0.30);
--blue-muted-bg-hover: rgba(65, 173, 255, 0.22);
--yellow-muted-bg: rgba(243, 156, 18, 0.12);
--yellow-muted-border: rgba(243, 156, 18, 0.30);
--yellow-muted-bg-hover: rgba(243, 156, 18, 0.22);
--green-muted-bg: rgba(76, 175, 80, 0.12);
--green-muted-border: rgba(76, 175, 80, 0.30);
--green-muted-bg-hover: rgba(76, 175, 80, 0.22);
--danger-border-muted: rgba(242, 90, 90, 0.35);
}

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Calque_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 283.5 283.5">
<!-- Generator: Adobe Illustrator 30.0.0, SVG Export Plug-In . SVG Version: 2.1.1 Build 123) -->
<defs>
<style>
.st0 {
fill: #009992;
}
.st1 {
fill: #bb59ff;
}
</style>
</defs>
<g>
<path class="st1" d="M86.6,135.9c-1.5,0-2.8-.3-4-1-1.3-.7-2.4-1.7-3-3l-23.5-39.1c-.3.8-.7,1.5-1.3,2.1l-30.3,38s0,0-.1.1c-1.3,1.5-3.1,2.4-5.2,2.7-.5,0-.9,0-1.3,0-1.9,0-3.5-.6-4.9-1.9-1.7-1.5-2.7-3.4-2.8-5.4-.2-2.3.4-4.3,1.8-5.9l29.9-37.4c0-.2.2-.3.3-.5,1.3-1.9,3.3-2.9,5.6-3.1.2,0,.5,0,.7,0,.3,0,.7,0,1,0L11.1,17.9c-1.1-1.9-1.4-3.8-.9-6.1.5-2.2,1.8-4,3.8-5.1,1.2-.7,2.5-1,3.9-1s1.3,0,2,.2c2,.5,3.8,1.7,5,3.6l23.6,39.3c.3-.7.7-1.4,1.3-2.1l30.3-38c1.5-1.8,3.4-2.8,5.5-3,.3,0,.5,0,.8,0,1.9,0,3.6.6,5.1,1.7,1.8,1.4,2.8,3.3,3,5.5.2,2.9-.9,4.7-1.9,5.8l-29.7,37.2c0,.2-.2.5-.4.7-1.3,1.9-3.3,2.9-5.6,3.1-.2,0-.5,0-.7,0s-.7,0-1,0l38.4,63.9c1.1,1.9,1.4,3.9.9,6-.5,2-1.7,3.7-3.5,4.9-1.2.8-2.6,1.2-4.2,1.2Z"/>
<path class="st0" d="M17.8,8.7c.4,0,.9,0,1.3.2,1.3.3,2.3,1.1,3.1,2.2l68.5,114.1c.7,1.2.8,2.4.5,3.8-.3,1.3-1.1,2.3-2.2,3.1-.7.5-1.5.7-2.4.7s-1.8-.2-2.5-.6c-.8-.4-1.4-1-1.7-1.7L13.7,16.3c-.7-1.2-.8-2.4-.5-3.9.3-1.4,1.1-2.4,2.2-3.1.8-.5,1.6-.7,2.4-.7M86.3,8.8c1.2,0,2.3.3,3.2,1,1,.8,1.7,1.9,1.8,3.4.1,1.4-.2,2.7-1.1,3.6l-30.4,38h.1c-.8,1.2-1.9,1.8-3.4,1.9-.2,0-.3,0-.5,0-1.2,0-2.3-.3-3.2-1-1-.8-1.6-1.9-1.8-3.4-.2-1.4.1-2.7,1-3.7l30.4-38c.9-1.1,2-1.7,3.4-1.8.2,0,.3,0,.5,0M48.5,84.8c1.2,0,2.3.3,3.2,1.1,1,.9,1.6,2,1.8,3.4.2,1.4-.1,2.7-1,3.7l-30.4,38c-.9,1-2,1.5-3.4,1.7-.3,0-.6,0-.8,0-1.1,0-2-.3-2.9-1.1-1-.9-1.7-2-1.8-3.4-.1-1.4.2-2.7,1.1-3.7l30.4-38h-.1c.8-1.1,1.9-1.7,3.4-1.8.2,0,.3,0,.5,0M17.8,2.6h0c-1.9,0-3.7.5-5.4,1.5-2.7,1.5-4.6,4-5.2,7.1-.6,3-.2,5.8,1.2,8.2,0,0,0,0,0,0l36,59.8c-1.9.7-3.6,2-4.8,3.7-.1.2-.3.4-.4.6l-29.7,37.1c-1.3,1.6-2.8,4.2-2.5,8,.2,2.8,1.5,5.4,3.8,7.5,1.9,1.7,4.3,2.7,6.9,2.7s1.1,0,1.7-.1c0,0,0,0,0,0,2.8-.4,5.2-1.7,7-3.7,0,0,.2-.2.2-.3l29.1-36.4,21,35c.9,1.8,2.4,3.3,4.2,4.2,1.6.9,3.5,1.3,5.4,1.3s4.2-.6,5.9-1.8c2.5-1.6,4.2-4,4.8-6.8.7-2.9.3-5.7-1.2-8.2,0,0,0,0,0,0l-36-59.9c1.9-.7,3.6-2,4.8-3.7.2-.3.4-.6.5-.9l29.4-36.8c1.4-1.5,2.9-4.1,2.6-8-.2-3-1.7-5.8-4.1-7.7-1.9-1.6-4.4-2.4-7-2.4s-.6,0-1,0c-3,.2-5.7,1.6-7.7,4.1,0,0,0,0,0,0l-29,36.3L27.5,8s0,0,0-.1c-1.6-2.5-4-4.3-6.8-4.9-.9-.2-1.8-.3-2.7-.3h0Z"/>
</g>
<g>
<path class="st1" d="M105.1,199.1c-.5,0-.9,0-1.4-.1,0,0-.1,0-.2,0-2.2-.5-3.9-1.7-5-3.7-1-1.8-1.4-3.7-1.1-5.8,0,0,0-.2,0-.3l26.6-114.1s0,0,0-.1c.6-2.1,1.8-3.7,3.6-4.9,0,0,0,0,.1,0,1.3-.8,2.7-1.2,4.1-1.2s1.2,0,1.9.2c2,.5,3.8,1.7,5,3.6,0,0,0,0,0,.1.3.5.5,1,.7,1.4.2-.6.5-1.1.8-1.6,1.2-1.9,2.9-3.1,5-3.6.6-.1,1.2-.2,1.9-.2,1.4,0,2.8.4,4.1,1.2,1.8,1,3.1,2.7,3.7,4.9,0,0,0,.1,0,.2l26.6,114.1c.3,1.4.3,2.7,0,4-.4,1.5-1.2,2.7-2,3.5-1.2,1.4-2.8,1.9-3.8,2.2,0,0,0,0,0,0-.6.1-1.2.2-1.7.2-1.1,0-2.1-.2-3.2-.7-.4,0-.7-.2-1-.4-1.9-1.1-3.2-2.9-3.7-5.1l-7.7-32.6c-1.1.5-2.3.8-3.6.8h-30.4c-1.3,0-2.5-.3-3.6-.8l-7.7,32.6c-.5,2.2-1.7,4-3.7,5.1-1.4.8-2.8,1.2-4.4,1.2ZM154.7,144.7l-15.2-64.9-15.2,64.9h30.4Z"/>
<path class="st0" d="M147.1,71.7c.9,0,1.7.2,2.6.7,1.2.7,1.9,1.8,2.3,3.2l26.6,114.1c.2.9.2,1.7,0,2.5-.2.8-.7,1.5-1.3,2.2-.6.7-1.4,1-2.2,1.2-.4,0-.7.1-1.1.1-1,0-1.9-.3-2.8-.8v.2c-1.1-.7-1.8-1.8-2.1-3.2l-9.2-39.1c0,1.3-.5,2.5-1.5,3.5-1,1-2.1,1.4-3.6,1.4h-30.4c-1.4,0-2.5-.5-3.6-1.4-1-.9-1.4-2.1-1.5-3.5l-9.2,39.1c-.3,1.4-1,2.4-2.2,3.2-.9.5-1.9.8-2.8.8s-.6,0-.9,0c-1.3-.3-2.3-1-3.1-2.2-.7-1.2-.9-2.4-.7-3.9l26.6-114.1c.4-1.4,1.2-2.4,2.3-3.2.9-.5,1.7-.7,2.6-.7s.8,0,1.2.1c1.3.3,2.3,1.1,3.1,2.2.7,1.2.9,2.4.7,3.7l-17,72.6c0,0,0,.2,0,.2v-.2c0,0,0,0,.1,0,.4-.8,1-1.4,1.8-1.8.8-.4,1.6-.7,2.5-.7h30.4c1.4,0,2.5.5,3.6,1.4,1,.9.7.8.9,1.2l-17-72.7c-.3-1.3,0-2.5.7-3.7.7-1.1,1.7-1.9,3.1-2.2.4,0,.8-.1,1.2-.1M147.1,65.6c-.8,0-1.7,0-2.5.3-1.9.4-3.7,1.4-5.1,2.8-1.4-1.3-3.1-2.2-5-2.7-.8-.2-1.7-.3-2.5-.3-2,0-3.9.5-5.7,1.6,0,0-.1,0-.2.1-2.4,1.6-4.1,3.8-4.9,6.6,0,0,0,.2,0,.3l-26.6,114.1c0,.2,0,.3,0,.5-.4,2.8,0,5.4,1.5,7.8,1.6,2.7,4,4.4,7,5.1.1,0,.3,0,.4,0,.6,0,1.3.1,1.9.1,2.1,0,4.1-.5,5.9-1.6,2.7-1.6,4.5-4,5.1-7.1l6.9-29.5c.5,0,.9,0,1.4,0h30.4c.5,0,.9,0,1.4,0l6.9,29.5c.7,3,2.5,5.5,5.1,7,.5.3,1.1.5,1.7.7,1.3.5,2.7.8,4.1.8s1.6,0,2.4-.3c0,0,.1,0,.2,0,.8-.2,3.4-.9,5.4-3.2.9-1.1,2.1-2.6,2.6-4.7.4-1.8.4-3.5,0-5.4,0,0,0,0,0,0l-26.6-114.1c0-.1,0-.3-.1-.4-1.1-3.7-3.4-5.7-5.1-6.6-1.8-1.1-3.7-1.6-5.7-1.6h0ZM128.2,141.7l11.3-48.4,11.3,48.4h-22.7Z"/>
</g>
<g>
<path class="st1" d="M266.3,279.3c-1.1,0-2.5,0-3.9-.7-1.1-.6-2.1-1.3-3-2.4-1.2-1.4-1.5-3-1.6-4.2l-11-111.3s-7.2,28.8-7.3,28.8c-.8,2.7-2.6,4.7-5.2,5.6,0,0,0,0-.1,0-.8.2-1.7.4-2.5.4s-1.3,0-1.9-.2c-1.3-.3-2.5-1-3.6-1.9-1.2-1-2-2.4-2.3-4.1l-7.3-28.9-11.1,111.4c0,2.2-1.1,4.1-2.9,5.6-1.5,1.2-3.3,1.8-5.2,1.8s-.4,0-.6,0c-2.2-.2-4.1-1.1-5.5-2.8-1.4-1.6-2.1-3.6-1.9-5.8l11.5-114.1c0-2.2,1.1-4.1,2.9-5.6,1.5-1.3,3.3-1.9,5.2-1.9s.3,0,.5,0c2.2,0,4.2,1.1,5.6,2.9.6.8,1.1,1.6,1.4,2.5.2-.5.4-1,.7-1.6,1.2-2.1,3.1-3.4,5.2-3.7.5,0,1-.1,1.4-.1,1.4,0,2.8.3,4.2,1,0,0,.1,0,.2,0,1.6,1,2.8,2.3,3.4,4,.8-2.2,2.3-3.5,3.5-4.1,1.3-.7,2.7-1.1,4.1-1.1s1.2,0,1.9.2c2.2.5,3.9,1.8,5,3.7.3.4.5.9.7,1.4.3-.9.8-1.7,1.5-2.5,1.5-1.6,3.3-2.6,5.4-2.7,0,0,.2,0,.2,0,1,0,2.1,0,3.4.5.2,0,.4.1.6.2,1.4.7,2.4,1.5,3.2,2.6.9,1.2,1.4,2.6,1.4,4.2l11.5,114c0,.1,0,.2,0,.3,0,1.5-.2,3.6-2,5.6-1.4,1.6-3.1,2.5-5.2,2.8-.2,0-.3,0-.5,0Z"/>
<path class="st0" d="M239.2,152.1c.4,0,.8,0,1.2.1,1.3.3,2.3,1,3.1,2.2.7,1.2.9,2.4.7,3.8l-7.6,30.4c-.5,1.7-1.5,3-3.3,3.6-.6.2-1.1.2-1.6.2s-.8,0-1.2-.1c-.8-.2-1.6-.6-2.3-1.2-.7-.6-1.1-1.4-1.3-2.4l-7.6-30.4c-.3-1.3,0-2.5.7-3.8.7-1.2,1.7-2,3.1-2.2.3,0,.7,0,1,0,1,0,1.9.2,2.8.7,1.2.7,1.9,1.7,2.2,3.1l2.7,10.7v.2c0,0,0,0,0,0v-.2c0,0,2.7-10.7,2.7-10.7.3-1.4,1-2.4,2.2-3.1.9-.5,1.7-.7,2.6-.7M209,152.1c.1,0,.3,0,.4,0,1.4,0,2.5.7,3.4,1.7.8,1,1.2,2.3,1,3.7l-11.3,114.1c0,1.4-.7,2.5-1.7,3.4-.9.7-2.1,1.1-3.3,1.1s-.3,0-.4,0c-1.4-.1-2.5-.7-3.4-1.7-.9-1-1.3-2.2-1.2-3.6l11.5-114.1c0-1.4.7-2.5,1.7-3.4,1-.8,2.1-1.2,3.3-1.2M253.9,152.1c.9,0,1.7,0,2.5.4h.1c.8.4,1.5.9,2,1.6.5.7.8,1.5.8,2.5l11.5,114.1c0,1.4-.3,2.5-1.2,3.6-.9,1-2,1.6-3.4,1.8-.9,0-1.7,0-2.5-.4-.8-.4-1.4-.9-2-1.6-.6-.7-.8-1.5-.9-2.5l-11.3-114.1c-.2-1.4.1-2.7,1-3.7.9-1,2-1.6,3.4-1.7M239.2,145.9h0c-1.9,0-3.8.5-5.5,1.5-.7.3-1.4.8-2.1,1.4-.6-.5-1.2-.9-1.8-1.3-.1,0-.2-.1-.3-.2-1.8-.9-3.6-1.3-5.5-1.3s-1.3,0-1.9.1c-2.1.3-4,1.3-5.5,2.7-1.9-1.8-4.3-2.9-6.9-2.9-.2,0-.4,0-.6,0-2.6,0-5,.9-7.1,2.6-2.5,2-3.9,4.7-4,7.7l-11.5,113.9s0,0,0,.1c-.2,2.9.7,5.8,2.7,8,1.9,2.3,4.6,3.7,7.6,3.9.3,0,.5,0,.8,0,2.6,0,5.1-.9,7.1-2.5,2.5-2,3.9-4.7,4-7.8l9.3-93.9,3,11.9c.5,2.3,1.6,4.3,3.3,5.7,1.4,1.2,3.1,2.1,4.8,2.5.9.2,1.8.3,2.6.3s2.2-.2,3.4-.5c0,0,.2,0,.3,0,3.5-1.2,6-3.9,7.1-7.6,0,0,0-.2,0-.2l2.9-11.7,9.3,93.8c.2,1.5.6,3.8,2.4,5.9,1.2,1.4,2.4,2.4,3.9,3.1,2.1,1.1,4,1.1,5.3,1.1s.6,0,.9,0c2.7-.4,5.1-1.7,7-3.8,1.9-2.1,2.8-4.6,2.8-7.7s0-.4,0-.6l-11.5-113.9c0-2.1-.7-4-1.9-5.7-1.1-1.5-2.5-2.6-4.3-3.5-.3-.2-.7-.3-1-.4-1.7-.7-3.3-.7-4.4-.7s-.3,0-.5,0c-2.5.2-4.7,1.2-6.6,2.9-1.4-1.3-3.1-2.2-5-2.6-.8-.2-1.7-.3-2.5-.3h0Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,10 @@
<!-- Favicon and icon links - add to your <head> section -->
<link rel="apple-touch-icon" sizes="152x152" href="/favicon/apple-touch-icon-152x152.png">
<link rel="apple-touch-icon" sizes="167x167" href="/favicon/apple-touch-icon-167x167.png">
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon-180x180.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png">
<link rel="manifest" href="/favicon/site.webmanifest">
<link rel="shortcut icon" href="/favicon/favicon.ico">
<meta name="msapplication-TileColor" content="\#ffffff">
<meta name="theme-color" content="\#ffffff">

View File

@@ -0,0 +1,19 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/favicon/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/favicon/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
app/public/assets/js/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1005
app/public/assets/js/overtype.min.js vendored Normal file

File diff suppressed because one or more lines are too long

70
app/public/index.php Normal file
View File

@@ -0,0 +1,70 @@
<?php
// Load configuration
require_once __DIR__ . '/../bootstrap.php';
require_once APP_ROOT . '/src/Controllers/HomeController.php';
$controller = HomeController::create();
$vars = $controller->handle();
extract($vars);
?>
<?php include APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<?php if ($year): ?>
<p class="filter-info" role="status">
Année : <?= htmlspecialchars($year) ?>
<a href="index.php" class="clear-filter"><span aria-hidden="true">✕</span> Réinitialiser</a>
</p>
<?php elseif ($isDefaultView): ?>
<p class="home-section-label" role="status">Publication récente</p>
<?php endif; ?>
<main class="home-main" id="main-content">
<h1 class="sr-only">Mémoires de l'ERG</h1>
<ul class="cards-container">
<?php foreach ($itemsToLoad as $item): ?>
<li class="card">
<a href="tfe.php?id=<?= (int)$item["id"] ?>">
<?php
// Resolve thumbnail: banner_path → cover file → gradient placeholder
$thumb = null;
// 1. Banner path (dedicated home thumbnail)
if (!empty($item['banner_path'])) {
$thumb = $item['banner_path'];
}
// 2. Cover image from thesis_files (batch-loaded above)
if (!$thumb && isset($coverMap[$item['id']])) {
$thumb = $coverMap[$item['id']];
}
// 3. Fall through to gradient
?>
<?php if ($thumb): ?>
<figure>
<img src="/media.php?path=<?= urlencode($thumb) ?>"
alt="Couverture — <?= htmlspecialchars($item['title']) ?> par <?= htmlspecialchars($item['authors'] ?? '') ?>"
loading="lazy">
</figure>
<?php else: ?>
<div class="card__media--gradient"
aria-hidden="true">
<span class="card__gradient-author"><?= htmlspecialchars($item['authors'] ?? '') ?></span>
<span class="card__gradient-title"><?= htmlspecialchars($item['title']) ?></span>
</div>
<?php endif; ?>
<p><?= htmlspecialchars($item["authors"] ?? '') ?><?php if (!empty($item['authors']) && !empty($item['title'])): ?> <?php endif; ?><?= htmlspecialchars($item["title"]) ?><?php if (!empty($item['year'])): ?><span class="sr-only">, <?= (int)$item['year'] ?></span><?php endif; ?></p>
</a>
</li>
<?php endforeach; ?>
<?php if (empty($itemsToLoad)): ?>
<li class="cards-empty">Aucun mémoire trouvé.</li>
<?php endif; ?>
</ul>
<?php include APP_ROOT . '/templates/partials/pagination.php'; ?>
</main>
<?php include APP_ROOT . '/templates/footer.php'; ?>

48
app/public/licence.php Normal file
View File

@@ -0,0 +1,48 @@
<?php
require_once __DIR__ . '/../bootstrap.php';
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/Parsedown.php';
$currentNav = 'licence';
try {
$db = Database::getInstance();
$dbPage = $db->getPage('licenses');
$content = $dbPage ? $dbPage['content'] : '';
$licencePageTitle = $dbPage ? $dbPage['title'] : 'Licences';
} catch (Exception $e) {
error_log("Error loading licence page: " . $e->getMessage());
$content = '';
$licencePageTitle = 'Licences';
}
$pd = new Parsedown();
$pd->setSafeMode(true);
$html = $pd->text($content);
$pageTitle = $licencePageTitle . ' Posterg';
$metaDescription = 'Informations sur les licences d\'utilisation des mémoires publiés sur Posterg, le répertoire des TFE de l\'erg.';
$ogTags = [
'type' => 'website',
'title' => $pageTitle,
'description' => $metaDescription,
'url' => 'https://posterg.erg.be/licence.php',
'site_name' => 'Posterg ERG',
];
$extraCss = ['/assets/css/apropos.css'];
$bodyClass = 'apropos-body';
?>
<?php include APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<main class="apropos-main" id="main-content">
<div class="prose apropos-single">
<?php if (!empty(trim($content))): ?>
<?= $html ?>
<?php else: ?>
<p>Contenu à venir.</p>
<?php endif; ?>
</div>
</main>
<?php include APP_ROOT . '/templates/footer.php'; ?>

View File

@@ -0,0 +1,55 @@
<?php
/**
* Simple live-reload endpoint for development.
* Polls file mtimes across all source directories and returns
* whether anything changed since last check.
*
* Only active when served by PHP's built-in CLI server.
*/
if (php_sapi_name() !== 'cli-server') {
http_response_code(403);
exit;
}
header('Content-Type: application/json');
$root = dirname(__DIR__);
$watchDirs = [
$root . '/public',
$root . '/src',
$root . '/config',
];
$watchExts = ['php', 'css', 'js', 'html'];
$hash = '';
foreach ($watchDirs as $dir) {
if (!is_dir($dir)) continue;
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($it as $file) {
if (in_array($file->getExtension(), $watchExts)) {
$hash .= $file->getMTime() . '|' . $file->getPathname() . "\n";
}
}
}
$fingerprint = md5($hash);
$stateFile = sys_get_temp_dir() . '/posterg-live-reload.txt';
$prev = file_exists($stateFile) ? file_get_contents($stateFile) : null;
// First visit: write baseline, don't fire a reload
if ($prev === null) {
file_put_contents($stateFile, $fingerprint);
$changed = false;
} else {
$changed = $fingerprint !== $prev;
if ($changed) {
file_put_contents($stateFile, $fingerprint);
}
}
echo json_encode(['changed' => $changed]);

View File

@@ -0,0 +1,59 @@
<?php
// This page is served directly by nginx / bootstrap when maintenance mode is active.
// It is also served by the public gate in bootstrap.php.
http_response_code(503);
header('Retry-After: 3600');
?><!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Maintenance Posterg</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #0d0d0d;
color: #e0e0e0;
font-family: 'Helvetica Neue', Arial, sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.box {
max-width: 520px;
text-align: center;
}
.box__logo {
font-size: 1.1rem;
font-weight: 700;
letter-spacing: .12em;
text-transform: uppercase;
color: #fff;
margin-bottom: 2.5rem;
}
.box__title {
font-size: 1.6rem;
font-weight: 300;
letter-spacing: .04em;
margin-bottom: 1rem;
}
.box__text {
font-size: .95rem;
color: #999;
line-height: 1.7;
}
</style>
</head>
<body>
<div class="box">
<div class="box__logo">POSTERG</div>
<h1 class="box__title">Maintenance en cours</h1>
<p class="box__text">
Le site est temporairement indisponible pour des raisons de maintenance.<br>
Merci de réessayer dans quelques instants.
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,28 @@
# Route all partage requests to index.php
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L]
</IfModule>
# Security headers
<IfModule mod_headers.c>
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-Content-Type-Options "nosniff"
Header always set X-XSS-Protection "1; mode=block"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;"
</IfModule>
# Prevent directory listing
Options -Indexes
# Protect dotfiles
<FilesMatch "^\.">
Require all denied
</FilesMatch>

View File

@@ -0,0 +1,590 @@
<?php
/**
* Partage — Entry point for shared student submission forms.
*
* Routes:
* /partage/<slug> — Render the share-link form (or password gate)
* /partage/<slug>/submit — POST endpoint for form submissions via share link
* /partage/thanks.php?id=N — Post-submission confirmation page
*/
require_once __DIR__ . '/../../bootstrap.php';
// Parse the requested path from REQUEST_URI
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
// Remove query string
$basePath = parse_url($requestUri, PHP_URL_PATH);
// Extract the part after /partage/
$path = trim(str_replace('/partage/', '', $basePath), '/');
// Split into parts: /partage/<slug>/<action>
$parts = explode('/', $path);
$slug = $parts[0] ?? '';
$action = $parts[1] ?? '';
// Validate slug format: YYYYMMDD-XXXXXXXX (17 chars)
if (!preg_match('#^\d{8}-[A-Z0-9+/]{8}$#', $slug)) {
App::boot();
$_SESSION['_flash_error'] = 'Ce lien de partage n\'est pas valide.';
header('Location: /');
exit;
}
// Boot for all requests: starts session, initialises DB, ensures CSRF token.
App::boot();
// ── POST: form submission ─────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'submit') {
handleShareLinkSubmission($slug);
exit;
}
// ── GET: render form ─────────────────────────────────────────────────────────
require_once APP_ROOT . '/src/ShareLink.php';
$shareLinkModel = new ShareLink(Database::getInstance());
$validationResult = $shareLinkModel->validateLink($slug);
if (!$validationResult['valid']) {
$reason = $validationResult['reason'];
if ($reason === 'not_found') {
$_SESSION['_flash_error'] = 'Ce lien de partage n\'existe pas ou a été supprimé.';
header('Location: /');
exit;
}
if ($reason === 'disabled') {
renderShareLinkError('Lien désactivé', 'Ce lien de partage a été désactivé par un administrateur.');
exit;
}
if ($reason === 'expired') {
renderShareLinkError('Lien expiré', 'Ce lien de partage a expiré.');
exit;
}
if ($reason === 'needs_password') {
// Show password gate
$link = $validationResult['link'];
requirePasswordGate($link, $slug);
exit;
}
$_SESSION['_flash_error'] = 'Erreur inattendue.';
header('Location: /');
exit;
}
// Link is valid — render the form
$link = $validationResult['link'];
renderShareLinkForm($slug, $link);
// ── Functions ─────────────────────────────────────────────────────────────────
/**
* Render a styled error page for invalid/expired/disabled share links.
*/
function renderShareLinkError(string $title, string $message): void
{
$pageTitle = htmlspecialchars($title);
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $pageTitle ?></title>
<link rel="stylesheet" href="<?= App::assetV('/assets/css/system.css') ?>">
<style>
.share-error {
max-width: 500px;
margin: 5rem auto;
padding: 2.5rem;
text-align: center;
background: #fff;
border: 2px solid #d00;
border-radius: 8px;
}
.share-error h1 {
color: #d00;
font-size: 1.5rem;
margin-bottom: 1rem;
}
.share-error p {
font-size: 1.1rem;
color: #444;
margin-bottom: 1.5rem;
}
.share-error a {
display: inline-block;
padding: 0.6rem 1.5rem;
background: #333;
color: white;
text-decoration: none;
border-radius: 4px;
}
.share-error a:hover {
background: #555;
}
</style>
</head>
<body>
<div class="share-error">
<h1><?= $pageTitle ?></h1>
<p><?= htmlspecialchars($message) ?></p>
<a href="/">← Retour à l'accueil</a>
</div>
</body>
</html>
<?php
}
function requirePasswordGate(array $link, string $slug): void
{
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['share_password'])) {
require_once APP_ROOT . '/src/ShareLink.php';
$shareLinkModel = new ShareLink(Database::getInstance());
if ($shareLinkModel->verifyPassword($link, $_POST['share_password'])) {
// Store verified status in session
$_SESSION['share_verified_' . $slug] = true;
// Redirect to clear POST data
header('Location: /partage/' . $slug);
exit;
} else {
$_SESSION['_flash_error'] = 'Mot de passe incorrect.';
header('Location: /partage/' . $slug);
exit;
}
}
$pageTitle = 'Accès protégé';
// Consume flash errors from wrong-password redirects
$flashError = $_SESSION['_flash_error'] ?? null;
unset($_SESSION['_flash_error']);
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle) ?></title>
<link rel="stylesheet" href="<?= App::assetV('/assets/css/system.css') ?>">
<style>
.password-gate {
max-width: 400px;
margin: 4rem auto;
padding: 2rem;
text-align: center;
}
.password-gate h1 { margin-bottom: 1.5rem; }
.password-gate input[type="password"] {
width: 100%;
padding: 0.5rem;
margin: 0.5rem 0 1rem;
font-size: 1rem;
}
.password-gate button {
padding: 0.75rem 2rem;
font-size: 1rem;
cursor: pointer;
}
.password-error {
color: red;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="password-gate">
<h1>🔒 Accès protégé</h1>
<?php if ($flashError): ?>
<p class="password-error"><?= htmlspecialchars($flashError) ?></p>
<?php endif; ?>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token'] ?? '') ?>">
<label for="share_password">Ce lien est protégé par un mot de passe :</label>
<input type="password" id="share_password" name="share_password" required autofocus>
<br>
<button type="submit">Accéder au formulaire</button>
</form>
</div>
</body>
</html>
<?php
}
function renderShareLinkForm(string $slug, array $link): void
{
require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php';
try {
$ctrl = ThesisCreateController::make();
extract($ctrl->loadFormData());
} catch (Exception $e) {
error_log('Failed to load form data: ' . $e->getMessage());
die('Erreur lors du chargement du formulaire.');
}
$formData = $_SESSION['form_data_share_' . $slug] ?? [];
unset($_SESSION['form_data_share_' . $slug]);
// Generate a CSRF token specific to this share link (stored in session)
$shareCsrfKey = 'share_csrf_' . $slug;
if (empty($_SESSION[$shareCsrfKey])) {
$_SESSION[$shareCsrfKey] = bin2hex(random_bytes(32));
}
$shareCsrfToken = $_SESSION[$shareCsrfKey];
$pageTitle = 'Soumettre un TFE';
// Determine if previously verified by password
$isVerified = !empty($_SESSION['share_verified_' . $slug]);
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle) ?></title>
<link rel="stylesheet" href="<?= App::assetV('/assets/css/system.css') ?>">
<link rel="stylesheet" href="<?= App::assetV('/assets/css/admin.css') ?>">
<style>
.student-body {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
}
.licence-explanation {
background: #f9f9f9;
border-left: 4px solid #666;
padding: 1.5rem;
margin: 2rem 0;
}
.licence-info h3 { margin-top: 1.5rem; }
.licence-degree { margin: 1.5rem 0; padding: 1rem; background: white; border: 1px solid #ddd; border-radius: 4px; }
.licence-note { color: #666; font-size: 0.9rem; margin-top: 0.5rem; }
.licence-generalites { margin-top: 2rem; }
.form-footer {
margin-top: 2rem;
padding-top: 1rem;
border-top: 2px solid #333;
}
.share-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
background: #e0f0ff;
border: 1px solid #99c;
border-radius: 3px;
font-size: 0.85rem;
margin-bottom: 1rem;
}
h1 { margin-bottom: 0.5rem; }
.mode-toggle { font-size: 0.9rem; }
</style>
</head>
<body class="admin-body student-body">
<main id="main-content">
<div class="thesis-add-header">
<h1>Soumettre un TFE</h1>
<?php if ($isVerified): ?>
<span class="share-badge">🔓 Accès partagé</span>
<?php endif; ?>
</div>
<?php
// Show flash messages from error redirect
$flashError = $_SESSION['_flash_error'] ?? null;
$flashSuccess = $_SESSION['_flash_success'] ?? null;
unset($_SESSION['_flash_error'], $_SESSION['_flash_success']);
?>
<?php if ($flashError): ?>
<div class="flash-error" role="alert"><?= htmlspecialchars($flashError) ?></div>
<?php endif; ?>
<?php if ($flashSuccess): ?>
<div class="flash-success" role="alert"><?= htmlspecialchars($flashSuccess) ?></div>
<?php endif; ?>
<form action="/partage/<?= urlencode($slug) ?>/submit" method="post" enctype="multipart/form-data" class="admin-form">
<input type="hidden" name="share_link_token" value="<?= htmlspecialchars($shareCsrfToken) ?>">
<!-- ═══════════════════ Informations du TFE ═══════════════════ -->
<fieldset>
<legend>Informations du TFE</legend>
<?php $name = 'titre'; $label = 'Titre :'; $value = old($formData, 'titre'); $required = true; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'subtitle'; $label = 'Sous-titre (si applicable) :'; $value = old($formData, 'subtitle'); $required = false; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'auteurice'; $label = 'Auteur·ice(s) :'; $value = old($formData, 'auteurice'); $required = true; $attrs = ['autocomplete' => 'name']; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'mail'; $label = 'Contact(s) (optionnel) [mail/site/insta/etc.] :'; $value = old($formData, 'mail'); $attrs = ['autocomplete' => 'email']; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<div class="admin-form-group">
<label class="admin-checkbox-label">
<input type="checkbox" name="contact_public" value="1"
<?= isset($formData['contact_public']) ? 'checked' : '' ?>>
Je veux que mon contact soit accessible à toustes depuis la plateforme xamxam
</label>
<small>Si cette case est cochée, votre contact apparaîtra sur la page publique de votre TFE.</small>
</div>
<div>
<label for="synopsis">Synopsis :</label>
<textarea id="synopsis" name="synopsis" rows="7" required><?= old($formData, 'synopsis') ?></textarea>
</div>
</fieldset>
<!-- ═══════════════════ Composition du jury ═══════════════════ -->
<fieldset>
<legend>Composition du jury</legend>
<?php $name = 'jury_president'; $label = 'Président·e du jury :'; $value = old($formData, 'jury_president'); include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'jury_promoteur'; $label = 'Promoteur·ice :'; $value = old($formData, 'jury_promoteur'); $hintCheckbox = ['name' => 'jury_promoteur_ext', 'label' => 'Externe à l\'erg', 'checked' => !empty($formData['jury_promoteur_ext'])]; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<div class="jury-additional">
<p>Lecteurs·rices (optionnel) :</p>
<?php for ($i = 0; $i < 4; $i++): ?>
<div class="jury-additional-row">
<input type="text"
name="jury_lecteurs[]"
placeholder="<?= $i === 0 ? 'Nom du lecteur·ice' : ''; ?>"
value="<?= old($formData, "jury_lecteurs:$i") ?>">
<label class="admin-checkbox-label">
<input type="checkbox" name="jury_lecteurs_ext[<?= $i ?>]" value="1"
<?= !empty($formData["jury_lecteurs_ext:$i"]) ? 'checked' : '' ?>>
Externe
</label>
</div>
<?php endfor; ?>
</div>
</fieldset>
<!-- ═══════════════════ Cadre académique ═══════════════════ -->
<fieldset>
<legend>Cadre académique</legend>
<?php
$name = 'année'; $label = 'Année :'; $value = old($formData, 'année'); $required = true;
$type = 'number';
$placeholder = date('Y');
$attrs = ['min' => 2000, 'max' => date('Y') + 1];
include APP_ROOT . '/templates/partials/form/text-field.php';
?>
<?php $name = 'orientation'; $label = 'Orientation :'; $options = $orientations; $selected = isset($formData['orientation']) ? $formData['orientation'] : ''; $required = true; $placeholder = ''; include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'ap'; $label = 'Atelier pluridisciplinaire :'; $options = $apPrograms; $selected = isset($formData['ap']) ? $formData['ap'] : ''; $required = true; $placeholder = ''; include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'finality'; $label = 'Finalité du master :'; $options = $finalityTypes; $selected = isset($formData['finality']) ? $formData['finality'] : ''; $required = true; $placeholder = ''; include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'languages'; $label = 'Langue(s) :'; $options = $languages; $checked = $formData['languages'] ?? []; include APP_ROOT . '/templates/partials/form/checkbox-list.php'; ?>
<?php $name = 'formats'; $label = 'Format(s) :'; $options = $formatTypes; $checked = $formData['formats'] ?? []; include APP_ROOT . '/templates/partials/form/checkbox-list.php'; ?>
<?php $name = 'tag'; $label = 'Mots-clés :'; $value = old($formData, 'tag'); $placeholder = 'sociologie, anthropologie, ...'; $hint = 'Séparez par des virgules. Max 10 mots-clés.'; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
</fieldset>
<!-- ═══════════════════ Fichiers ═══════════════════ -->
<fieldset>
<legend>Fichiers</legend>
<?php $name = 'couverture'; $label = 'Image de couverture :'; $accept = 'image/jpeg,image/png'; $hint = 'JPG, PNG. Taille max : 10 MB.'; include APP_ROOT . '/templates/partials/form/file-field.php'; ?>
<?php $name = 'banner'; $label = 'Image bannière (accueil) :'; $accept = 'image/jpeg,image/png,image/webp'; $hint = 'JPG, PNG ou WEBP. Format paysage recommandé (4:1). Max 5 MB.'; include APP_ROOT . '/templates/partials/form/file-field.php'; ?>
<?php $name = 'files'; $label = 'Fichiers du TFE :'; $accept = '.pdf,.jpg,.jpeg,.png,.mp4,.zip,.vtt'; $hint = 'PDF, JPG, PNG, MP4, ZIP. Max 50 MB par fichier. Pour les vidéos, un fichier .vtt de sous-titres peut être joint (il sera associé automatiquement à la vidéo correspondante).'; $multiple = true; include APP_ROOT . '/templates/partials/form/file-field.php'; ?>
</fieldset>
<!-- ═══════════════════ Métadonnées complémentaires ═══════════════════ -->
<fieldset>
<legend>Métadonnées complémentaires</legend>
<?php $name = 'license_id'; $label = 'Licence :'; $options = $licenseTypes; $selected = isset($formData['license_id']) ? $formData['license_id'] : ''; $placeholder = '— Inconnue —'; include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'duration_info'; $label = 'Durée / Taille :'; $value = old($formData, 'duration_info'); $placeholder = 'Ex : 84 pages'; $hint = 'Durée (minutes) ou nombre de pages.'; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'lien'; $label = 'Lien (site / ressource) :'; $value = old($formData, 'lien'); $type = 'url'; $placeholder = 'https://...'; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php
$accessOptions = array_map(function($at) {
return ['id' => $at['id'], 'name' => $at['name']];
}, $enabledAccessTypes);
$defaultAccessType = 2;
$selectedAccessType = isset($formData['access_type_id'])
? (int)$formData['access_type_id']
: $defaultAccessType;
$name = 'access_type_id';
$label = 'Visibilité / Accès :';
$options = $accessOptions;
$selected = $selectedAccessType;
$placeholder = null;
$required = true;
$attrs = [];
include APP_ROOT . '/templates/partials/form/select-field.php';
?>
</fieldset>
<!-- ═══════════════════ Degrés d'ouverture ═══════════════════ -->
<fieldset class="licence-explanation">
<legend>Degrés d'ouverture et licences</legend>
<div class="licence-info">
<h3>Je veux que mon TFE soit disponible sous les conditions suivantes :</h3>
<div class="licence-degree">
<h4>🔓 Libre</h4>
<p>Mon TFE est en libre accès à tout le monde sur la plateforme des TFE ainsi que dans la bibliothèque de l'erg. Je suis conscient·e des responsabilités et obligations légales qui viennent avec une diffusion externe et acquiesce avoir lu la documentation prévue à cet effet par l'erg, ainsi qu'avoir discuté des enjeux d'une publication avec l'équipe pédagogique. J'accepte de partager mes droits de diffusion avec l'erg, ce uniquement dans le cadre d'une diffusion sur la plateforme xamxam.</p>
<ul>
<li><label><input type="checkbox" name="cc4r" value="1"> J'accepte les conditions collectives de réutilisation (CC4r) <em class="hint">(pas obligatoire)</em></label></li>
<li><label><input type="checkbox" name="specific_license" value="1"> Je souhaite appliquer une licence spécifique à mon travail <em class="hint">(pas obligatoire)</em></label></li>
</ul>
<p class="licence-note"><em>Au moins une des deux cases doit être cochée pour le degré Libre.</em></p>
</div>
<div class="licence-degree">
<h4>🔒 Interne</h4>
<p>Mon TFE et ma note d'intention ne sont accessibles que sur place en physique ainsi que sur la plateforme xamxam par la communauté erg. Une note descriptive est disponible sur le site à toustes. J'autorise une (ré-)utilisation et diffusion dans un contexte académique et didactique au sein de l'erg.</p>
<p class="licence-note"><em>La diffusion limitée est protégée par le cadre académique/didactique, le travail pourrait donc être diffusé en interne et être cité par d'autres étudiant·es sans implications légales pour l'auteur·ice ni pour l'école.</em></p>
<ul>
<li><label><input type="checkbox" name="cc4r" value="1"> J'accepte les conditions collectives de réutilisation (CC4r) <em class="hint">(pas obligatoire)</em></label></li>
<li><label><input type="checkbox" name="specific_license" value="1"> Je souhaite appliquer une licence spécifique à mon travail <em class="hint">(pas obligatoire)</em></label></li>
</ul>
<p class="licence-note"><em>Au moins une des deux cases doit être cochée.</em></p>
</div>
<div class="licence-degree">
<h4>🚫 Interdit</h4>
<p>Mon TFE n'est pas disponible en physique ni sur le site. Une note descriptive est disponible sur le site.</p>
</div>
</div>
<div class="licence-generalites">
<h3>Généralités</h3>
<ul>
<li>L'auteur·ice peut décider entre trois degrés de partage de son travail : <strong>libre</strong>, <strong>interne</strong>, <strong>interdit</strong>.</li>
<li>L'auteur·ice peut, à tout moment, décider de <strong>restreindre</strong> le degré d'accès à son travail. Il ne peut néanmoins pas l'ouvrir davantage.</li>
<li>Le choix effectué dans ce formulaire sera d'application <strong>une semaine après la soutenance orale</strong> de l'auteur·ice. Celui-ci peut donc décider de restreindre ce choix avant sa publication (mais pas l'ouvrir).</li>
<li>L'erg se réserve le droit de restreindre le degré d'ouverture du TFE ce en accord avec le règlement.</li>
<li>Dans tous les cas, l'auteur·ice garde les droits d'auteurs, de diffusion, d'utilisation, etc. de son travail sauf si la licence choisie restreindrait ses droits.</li>
<li>La diffusion « xamxam » est indépendante de la diffusion à la BAIU.</li>
</ul>
</div>
</fieldset>
<div class="form-footer">
<button type="submit" name="go">Soumettre</button>
</div>
</form>
</main>
</body>
</html>
<?php
}
/**
* Handle share link form submission.
*/
function handleShareLinkSubmission(string $slug): void
{
// Session already started by App::boot() on the GET path; start here
// only if somehow not yet active (e.g. direct POST without prior GET).
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once APP_ROOT . '/src/ShareLink.php';
require_once APP_ROOT . '/src/RateLimit.php';
$shareLinkModel = new ShareLink(Database::getInstance());
$link = $shareLinkModel->findBySlug($slug);
if ($link === null || !$link['is_active'] || ($link['expires_at'] !== null && strtotime($link['expires_at']) < time())) {
$_SESSION['_flash_error'] = 'Ce lien n\'est plus valide.';
header('Location: /partage/' . urlencode($slug));
exit;
}
// ── Rate limiting ────────────────────────────────────────────────────────
// 5 submissions per IP per 10 minutes, keyed per share link.
$rateLimitCacheDir = STORAGE_ROOT . '/cache/rate_limit';
$shareRateLimitId = 'share_' . $slug . '_' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
$rateLimit = new RateLimit(5, 600, $rateLimitCacheDir);
if (!$rateLimit->checkKey($shareRateLimitId)) {
$_SESSION['_flash_error'] = 'Trop de tentatives. Veuillez réessayer plus tard.';
header('Location: /partage/' . urlencode($slug));
exit;
}
// ── End rate limiting ────────────────────────────────────────────────────
// Check password verification if link has a password
if ($link['password_hash'] !== null && empty($_SESSION['share_verified_' . $slug])) {
if (isset($_POST['share_password_submit'])) {
if ($shareLinkModel->verifyPassword($link, $_POST['share_password_submit'])) {
$_SESSION['share_verified_' . $slug] = true;
} else {
$_SESSION['_flash_error'] = 'Mot de passe incorrect.';
header('Location: /partage/' . urlencode($slug));
exit;
}
} else {
$_SESSION['_flash_error'] = 'Vous devez entrer le mot de passe avant de soumettre le formulaire.';
header('Location: /partage/' . urlencode($slug));
exit;
}
}
// Validate share-link CSRF token
$shareCsrfKey = 'share_csrf_' . $slug;
if (!isset($_POST['share_link_token'], $_SESSION[$shareCsrfKey])
|| !hash_equals($_SESSION[$shareCsrfKey], $_POST['share_link_token'])) {
error_log('Share link CSRF validation failed for ' . $slug);
$_SESSION['_flash_error'] = 'Token de sécurité invalide.';
header('Location: /partage/' . urlencode($slug));
exit;
}
require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php';
try {
$ctrl = ThesisCreateController::make();
$thesisId = $ctrl->submit($_POST, $_FILES);
// Mark the link as used
$shareLinkModel = new ShareLink(Database::getInstance());
$shareLinkModel->incrementUsage($link['id']);
// Clean up share-specific session data
unset($_SESSION[$shareCsrfKey]);
unset($_SESSION['share_verified_' . $slug]);
// Redirect to thanks page
header('Location: /partage/thanks.php?id=' . urlencode((string)$thesisId));
exit();
} catch (Exception $e) {
error_log('Share link submission error: ' . $e->getMessage());
$_SESSION['_flash_error'] = $e->getMessage();
$_SESSION['form_data_share_' . $slug] = $_POST;
$_SESSION[$shareCsrfKey] = bin2hex(random_bytes(32)); // Regenerate token
// Redirect back to the form
header('Location: /partage/' . urlencode($slug));
exit();
}
}
/**
* Helper to retrieve old form data (with support for array keys via : delimiter)
*/
function old(array $data, string $key, string $default = ''): string {
// Support nested keys like "jury_lecteurs:0"
$parts = explode(':', $key);
$value = $data;
foreach ($parts as $part) {
if (is_array($value) && isset($value[$part])) {
$value = $value[$part];
} else {
$value = $default;
break;
}
}
return is_array($value) ? htmlspecialchars(json_encode($value)) : htmlspecialchars((string)$value);
}

View File

@@ -0,0 +1,87 @@
<?php
/**
* Thanks page for share-link submissions.
* Displays a centered confirmation with a link to create another thesis via the same link.
*/
require_once __DIR__ . '/../../bootstrap.php';
App::boot();
$thesisId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if ($thesisId <= 0) {
http_response_code(400);
die('ID de TFE invalide.');
}
// Verify the thesis exists
$db = Database::getInstance();
$thesis = $db->getThesis($thesisId);
if (!$thesis) {
http_response_code(404);
die('TFE introuvable.');
}
// Get the share link slug from the referer path
$pathParts = explode('/', trim($_SERVER['REQUEST_URI'] ?? '', '/'));
$slug = count($pathParts) >= 2 ? $pathParts[0] : null;
$pageTitle = 'Merci — TFE enregistré';
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle) ?></title>
<link rel="stylesheet" href="<?= App::assetV('/assets/css/system.css') ?>">
<style>
.thanks-center {
max-width: 600px;
margin: 4rem auto;
padding: 2rem;
text-align: center;
}
.thanks-center h1 {
margin-bottom: 0.5rem;
font-size: 2rem;
}
.thanks-center p {
font-size: 1.1rem;
color: #555;
margin: 1rem 0 2rem;
}
.thanks-center .thesis-title {
font-weight: bold;
font-size: 1.2rem;
color: #333;
margin-bottom: 2rem;
}
.thanks-center .btn-add-another {
display: inline-block;
padding: 0.75rem 2rem;
background: #333;
color: white;
text-decoration: none;
border-radius: 4px;
font-size: 1rem;
transition: background 0.2s;
}
.thanks-center .btn-add-another:hover {
background: #555;
}
</style>
</head>
<body>
<div class="thanks-center">
<h1>✅ Merci !</h1>
<p>Votre TFE a bien été enregistré sur la plateforme.</p>
<?php if ($thesis): ?>
<div class="thesis-title"><?= htmlspecialchars($thesis['title']) ?> — <?= htmlspecialchars($thesis['authors'] ?? '') ?></div>
<?php endif; ?>
<?php if ($slug): ?>
<a href="/partage/<?= urlencode($slug) ?>" class="btn-add-another">+ Soumettre un autre TFE</a>
<?php endif; ?>
</div>
</body>
</html>

21
app/public/repertoire.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
require_once __DIR__ . '/../bootstrap.php';
require_once APP_ROOT . '/src/Controllers/SearchController.php';
// Build controller (performs rate-limit check; exits with HTTP 429 if exceeded)
$ctrl = SearchController::create();
// Collect all view variables for the répertoire index page
extract($ctrl->handleRepertoire());
?>
<?php include APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<main class="search-main" id="main-content">
<h1 class="sr-only">Répertoire</h1>
<span id="rep-indicator" class="rep-indicator htmx-indicator" aria-hidden="true"></span>
<?php include APP_ROOT . '/templates/partials/repertoire-index.php'; ?>
</main>
<script src="/assets/js/htmx.min.js"></script>
<?php include APP_ROOT . '/templates/footer.php'; ?>

82
app/public/search.php Normal file
View File

@@ -0,0 +1,82 @@
<?php
require_once __DIR__ . '/../bootstrap.php';
require_once APP_ROOT . '/src/Controllers/SearchController.php';
// Build controller (performs rate-limit check; exits with HTTP 429 if exceeded)
$ctrl = SearchController::create();
// Collect all view variables for the search results page
extract($ctrl->handleSearch());
?>
<?php include APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<?php if ($validationError): ?>
<div class="search-error">⚠ <?= htmlspecialchars($validationError) ?></div>
<?php endif; ?>
<!-- Filter controls -->
<form class="search-controls" method="GET" action="search.php">
<input type="hidden" name="query" value="<?= htmlspecialchars($_GET['query'] ?? '') ?>">
<label class="search-filter-label" for="filter-year">Année
<select class="search-filter-select" name="year" id="filter-year">
<option value="">Toutes</option>
<?php foreach ($years as $y): ?>
<option value="<?= (int)$y ?>" <?= (isset($_GET['year']) && $_GET['year'] == $y) ? 'selected' : '' ?>>
<?= (int)$y ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="search-filter-label" for="filter-orientation">Orientation
<select class="search-filter-select" name="orientation" id="filter-orientation">
<option value="">Toutes</option>
<?php foreach ($orientations as $o): ?>
<option value="<?= htmlspecialchars($o['name']) ?>"
<?= (isset($_GET['orientation']) && $_GET['orientation'] == $o['name']) ? 'selected' : '' ?>>
<?= htmlspecialchars($o['name']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="search-filter-label" for="filter-ap">AP
<select class="search-filter-select" name="ap_program" id="filter-ap">
<option value="">Tous</option>
<?php foreach ($apPrograms as $ap): ?>
<option value="<?= htmlspecialchars($ap['name']) ?>"
<?= (isset($_GET['ap_program']) && $_GET['ap_program'] == $ap['name']) ? 'selected' : '' ?>>
<?= htmlspecialchars($ap['name']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<button type="submit" class="search-apply-btn">Filtrer</button>
<a href="search.php?query=<?= urlencode($_GET['query'] ?? '') ?>" class="search-reset-link">Réinitialiser</a>
</form>
<main class="search-main" id="main-content">
<output class="search-results-header" role="status"><?= $totalItems ?> résultat<?= $totalItems > 1 ? 's' : '' ?></output>
<?php if (!empty($results)): ?>
<ul class="results-grid">
<?php foreach ($results as $item): ?>
<li><a href="tfe.php?id=<?= (int)$item['id'] ?>" class="result-card">
<span class="result-card__authors"><?= htmlspecialchars($item['authors'] ?? '') ?></span>
<span class="result-card__title"><?= htmlspecialchars($item['title']) ?></span>
<small class="result-card__meta"><?= htmlspecialchars($item['year']) ?><?php if (!empty($item['orientation'])): ?> · <?= htmlspecialchars($item['orientation']) ?><?php endif; ?></small>
</a></li>
<?php endforeach; ?>
</ul>
<?php include APP_ROOT . '/templates/partials/pagination.php'; ?>
<?php else: ?>
<p class="search-empty">Aucun résultat pour cette recherche.</p>
<?php endif; ?>
</main>
<?php include APP_ROOT . '/templates/footer.php'; ?>

263
app/public/tfe.php Normal file
View File

@@ -0,0 +1,263 @@
<?php
require_once __DIR__ . '/../bootstrap.php';
require_once APP_ROOT . '/src/Controllers/TfeController.php';
// Build controller (loads thesis, enforces visibility, builds OG tags; redirects on 404)
$ctrl = TfeController::create();
// Collect all view variables
extract($ctrl->handle());
?>
<?php include APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<main class="tfe-main" id="main-content">
<article class="tfe-layout">
<!-- LEFT: info — article header -->
<header class="tfe-left">
<!-- Author above title -->
<p class="tfe-author"><?= htmlspecialchars($data['authors'] ?? 'Auteur inconnu') ?></p>
<h1 class="tfe-title">
<?= htmlspecialchars($data['title']) ?>
<?php if (!empty($data['subtitle'])): ?>
<?= htmlspecialchars($data['subtitle']) ?>
<?php endif; ?>
</h1>
<dl>
<?php if (!empty($data['orientation'])): ?>
<div>
<dt>Orientation :</dt>
<dd><a href="/repertoire.php?or[]=<?= urlencode($data['orientation']) ?>"><?= htmlspecialchars($data['orientation']) ?></a></dd>
</div>
<?php endif; ?>
<?php if (!empty($data['ap_program'])): ?>
<div>
<dt>Atelier pluridisciplinaire :</dt>
<dd><a href="/repertoire.php?ap[]=<?= urlencode($data['ap_program']) ?>"><?= htmlspecialchars($data['ap_program']) ?></a></dd>
</div>
<?php endif; ?>
<?php if (!empty($data['year'])): ?>
<div>
<dt>Date :</dt>
<dd><a href="/repertoire.php?fy[]=<?= urlencode($data['year']) ?>"><?= htmlspecialchars($data['year']) ?></a></dd>
</div>
<?php endif; ?>
<?php if (!empty($data['languages'])): ?>
<div>
<dt>Langue :</dt>
<dd><?php
$langs = array_map('trim', explode(',', $data['languages']));
$langLinks = array_map(fn($l) => '<a href="/search.php?query=' . urlencode($l) . '">' . htmlspecialchars($l) . '</a>', $langs);
echo implode(', ', $langLinks);
?></dd>
</div>
<?php endif; ?>
<?php if (!empty($data['formats'])): ?>
<div>
<dt>Format :</dt>
<dd><?php
$fmts = array_map('trim', explode(',', $data['formats']));
$fmtLinks = array_map(fn($f) => '<a href="/search.php?query=' . urlencode($f) . '">' . htmlspecialchars($f) . '</a>', $fmts);
echo implode(', ', $fmtLinks);
?></dd>
</div>
<?php endif; ?>
<?php if (!empty($data['file_size_info'])): ?>
<div>
<dt>Durée :</dt>
<dd><?= htmlspecialchars($data['file_size_info']) ?></dd>
</div>
<?php endif; ?>
<?php if (!empty($data['keywords'])): ?>
<div>
<dt>Mots-clés :</dt>
<dd><?php
$kws = array_map('trim', explode(',', $data['keywords']));
$kwLinks = array_map(fn($k) => '<a href="/repertoire.php?kw[]=' . urlencode($k) . '">' . htmlspecialchars($k) . '</a>', $kws);
echo implode(', ', $kwLinks);
?></dd>
</div>
<?php endif; ?>
<?php if (!empty($promoteursInternes)): ?>
<div>
<dt>Promoteur·ice interne :</dt>
<dd><?php
$links = array_map(fn($n) => '<a href="/search.php?query=' . urlencode($n) . '">' . htmlspecialchars($n) . '</a>', $promoteursInternes);
echo implode(', ', $links);
?></dd>
</div>
<?php endif; ?>
<?php if (!empty($promoteursExternes)): ?>
<div>
<dt>Promoteur·ice externe :</dt>
<dd><?php
$links = array_map(fn($n) => '<a href="/search.php?query=' . urlencode($n) . '">' . htmlspecialchars($n) . '</a>', $promoteursExternes);
echo implode(', ', $links);
?></dd>
</div>
<?php endif; ?>
<?php if (!empty($juryPresidents)): ?>
<div>
<dt>Président·e du jury :</dt>
<dd><?php
$links = array_map(fn($n) => '<a href="/search.php?query=' . urlencode($n) . '">' . htmlspecialchars($n) . '</a>', $juryPresidents);
echo implode(', ', $links);
?></dd>
</div>
<?php endif; ?>
<?php if (!empty($juryLecteurs)): ?>
<div>
<dt>Lecteur·ices :</dt>
<dd><?php
$links = array_map(fn($n) => '<a href="/search.php?query=' . urlencode($n) . '">' . htmlspecialchars($n) . '</a>', $juryLecteurs);
echo implode(', ', $links);
?></dd>
</div>
<?php endif; ?>
<?php if (!empty($data['access_type'])): ?>
<div>
<dt>Accès :</dt>
<dd><?= htmlspecialchars($data['access_type']) ?></dd>
</div>
<?php endif; ?>
<?php if (!empty($data['license_type'])): ?>
<div>
<dt>Licence :</dt>
<dd><?= htmlspecialchars($data['license_type']) ?></dd>
</div>
<?php endif; ?>
<?php if (!empty($data['context_note'])): ?>
<div class="tfe-meta-note">
<dt>Note :</dt>
<dd class="tfe-note-value"><?= nl2br(htmlspecialchars($data['context_note'])) ?></dd>
</div>
<?php endif; ?>
<?php if (!empty($data['author_email']) && !empty($data['author_show_contact'])): ?>
<div>
<dt>Contact :</dt>
<dd>
<?php
$_contact = $data['author_email'];
$_isUrl = filter_var($_contact, FILTER_VALIDATE_URL) !== false;
$_isEmail = !$_isUrl && str_contains($_contact, '@');
if ($_isUrl):
?>
<a href="<?= htmlspecialchars($_contact) ?>" target="_blank" rel="noopener">
<?= htmlspecialchars(preg_replace('#^https?://#i', '', rtrim($_contact, '/'))) ?>
<span class="sr-only">(ouvre dans un nouvel onglet)</span>
</a>
<?php elseif ($_isEmail): ?>
<a href="mailto:<?= htmlspecialchars($_contact) ?>"><?= htmlspecialchars($_contact) ?></a>
<?php else: ?>
<?= htmlspecialchars($_contact) ?>
<?php endif; ?>
</dd>
</div>
<?php endif; ?>
<?php if (!empty($data['baiu_link'])): ?>
<?php
$_baiuHref = htmlspecialchars($data['baiu_link']);
$_baiuLabel = preg_replace('#^https?://#i', '', rtrim($data['baiu_link'], '/'));
?>
<div>
<dt>Lien :</dt>
<dd>
<a href="<?= $_baiuHref ?>" target="_blank" rel="noopener">
<?= htmlspecialchars($_baiuLabel) ?>
<span class="sr-only">(ouvre dans un nouvel onglet)</span>
</a>
</dd>
</div>
<?php endif; ?>
</dl>
<?php if (!empty($data['synopsis'])): ?>
<p class="tfe-synopsis-text">
<?= nl2br(htmlspecialchars($data['synopsis'])) ?>
</p>
<?php endif; ?>
</header>
<!-- RIGHT: media — supplementary aside -->
<aside class="tfe-right">
<?php
// $isInterdit and $captionFiles are resolved by TfeController::handle()
$_videoIndex = 0;
?>
<?php if ($isInterdit): ?>
<p class="tfe-restricted">
Ce TFE n'est pas disponible en ligne.
</p>
<?php elseif (!empty($data['files'])): ?>
<?php foreach ($data['files'] as $file): ?>
<?php
$ext = strtolower(pathinfo($file['file_path'], PATHINFO_EXTENSION));
// VTT caption files are consumed inline by <video>; skip standalone rendering.
if ($ext === 'vtt') continue;
?>
<figure>
<?php if ($ext === 'pdf'): ?>
<embed src="/media.php?path=<?= urlencode($file['file_path']) ?>"
type="application/pdf" width="100%" height="700px">
<p class="tfe-pdf-fallback">
<a href="/media.php?path=<?= urlencode($file['file_path']) ?>&download=1">
Télécharger le PDF
</a>
</p>
<?php elseif (in_array($ext, ['jpg','jpeg','png','gif','bmp','webp'])): ?>
<img src="/media.php?path=<?= urlencode($file['file_path']) ?>"
alt="<?= htmlspecialchars(
!empty($file['description'])
? $file['description']
: ($data['title'] . ' — ' . ($data['authors'] ?? ''))
) ?>">
<?php elseif ($ext === 'mp4'): ?>
<?php
// Pair this video with the N-th VTT file (if one was uploaded).
$_vttPath = $captionFiles[$_videoIndex] ?? null;
$_videoIndex++;
?>
<video width="100%" controls>
<source src="/media.php?path=<?= urlencode($file['file_path']) ?>" type="video/mp4">
<?php if ($_vttPath): ?>
<track kind="captions"
src="/media.php?path=<?= urlencode($_vttPath) ?>"
srclang="fr"
label="Sous-titres"
default>
<?php endif; ?>
</video>
<?php endif; ?>
<?php if (!empty($file['description'])): ?>
<figcaption><?= htmlspecialchars($file['description']) ?></figcaption>
<?php endif; ?>
</figure>
<?php endforeach; ?>
<?php else: ?>
<p class="tfe-no-files">Aucun fichier disponible pour ce TFE.</p>
<?php endif; ?>
</aside>
</article>
</main>
<?php include APP_ROOT . '/templates/footer.php'; ?>

26
app/router.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
/**
* Router script for PHP built-in development server (php -S).
*
* Routes /partage/<slug> to public/partage/index.php, since the built-in
* server has no URL rewriting like nginx's try_files.
*/
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
// Route /partage/<slug> and /partage/<slug>/<action> to the partage entry
if (preg_match('#^/partage(/.*)?$#', $uri)) {
$_SERVER['SCRIPT_NAME'] = '/partage/index.php';
require __DIR__ . '/public/partage/index.php';
return true;
}
// Route /tfe/<...> to tfe.php
if (preg_match('#^/tfe(/.*)?$#', $uri)) {
$_SERVER['SCRIPT_NAME'] = '/tfe.php';
require __DIR__ . '/public/tfe.php';
return true;
}
// Default: serve static files if they exist
return false;

170
app/src/AdminAuth.php Normal file
View File

@@ -0,0 +1,170 @@
<?php
/**
* 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.
*
* The admin password hash is stored in the site_settings table
* (key = 'admin_password_hash').
*
* If the hash is empty/missing the guard is a no-op (dev / cli-server).
*/
class AdminAuth
{
private const SESSION_KEY = 'admin_authenticated';
private const LOGIN_URL = '/admin/login.php';
/**
* Start the PHP session with hardened cookie parameters.
* Idempotent — safe to call even if session is already active.
*/
private static function startSession(): void
{
if (session_status() !== PHP_SESSION_NONE) {
return;
}
// Harden session cookie (item #8)
session_set_cookie_params([
'lifetime' => 0,
'path' => '/admin',
'secure' => (php_sapi_name() !== 'cli-server'),
'httponly' => true,
'samesite' => 'Strict',
]);
session_start();
}
/**
* Fetch the admin password hash from site_settings.
* Returns null if not set (dev mode).
*/
private static function getStoredHash(): ?string
{
// Legacy fallback: if the old constant is still defined, honour it.
if (defined('ADMIN_PASSWORD_HASH') && ADMIN_PASSWORD_HASH !== '') {
return ADMIN_PASSWORD_HASH;
}
// Lazy-load minimal DB just for this lookup.
require_once APP_ROOT . '/src/Database.php';
$db = new Database();
$hash = $db->getSetting('admin_password_hash');
return $hash !== '' ? $hash : null;
}
/**
* Gate every admin page.
*
* 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.
*/
public static function requireLogin(): void
{
self::startSession();
$storedHash = self::getStoredHash();
if ($storedHash === null) {
return; // No password configured → dev / cli-server, skip.
}
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)) {
return;
}
header('Location: ' . self::LOGIN_URL);
exit;
}
/**
* Validate a plaintext password against the stored hash.
* On success: regenerates the session ID and marks the session authenticated.
*
* @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)) {
return false;
}
self::startSession();
session_regenerate_id(true);
$_SESSION[self::SESSION_KEY] = true;
$_SESSION['admin_login_at'] = time();
return true;
}
/**
* Bcrypt verification wrapper.
*/
private static function verifyHash(string $password, string $hash): bool
{
return password_verify($password, $hash);
}
/**
* Update the stored admin password hash in the database.
*/
public static function setPasswordHash(string $newHash): void
{
require_once APP_ROOT . '/src/Database.php';
$db = new Database();
$db->setSetting('admin_password_hash', $newHash);
}
/**
* Remove the stored admin password hash (revert to dev mode).
*/
public static function removePasswordHash(): void
{
require_once APP_ROOT . '/src/Database.php';
$db = new Database();
$db->setSetting('admin_password_hash', '');
}
/**
* Check whether the current request is authenticated (without redirecting).
*/
public static function isAuthenticated(): bool
{
self::startSession();
$storedHash = self::getStoredHash();
if ($storedHash === null) {
return true; // No password configured → dev mode.
}
return !empty($_SESSION[self::SESSION_KEY]);
}
/**
* Check whether a password hash is configured in the system.
*/
public static function hasPassword(): bool
{
return self::getStoredHash() !== null;
}
/**
* Destroy the session (logout).
*/
public static function logout(): void
{
self::startSession();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(
session_name(), '', time() - 86400,
$p['path'], $p['domain'], $p['secure'], $p['httponly']
);
}
session_destroy();
}
}

180
app/src/App.php Normal file
View File

@@ -0,0 +1,180 @@
<?php
/**
* Thin application helper — centralises bootstrap, auth gating, CSRF lifecycle,
* flash messages, redirect, and template rendering.
*
* Eliminates the ~6-8 lines of identical preamble repeated across every page
* and action handler. See REFACTORING_RECOMMENDATIONS.md §1 + §3.
*/
class App
{
private static bool $booted = false;
// ── Bootstrap ─────────────────────────────────────────────────────────────
/**
* Boot once per request: load Database, ensure CSRF token exists.
* Suitable for public pages (no auth required).
*/
public static function boot(): Database
{
if (!self::$booted) {
require_once APP_ROOT . '/src/Database.php';
self::$booted = true;
}
self::ensureCsrf();
return Database::getInstance();
}
/**
* Gate for admin pages: require auth + CSRF token + load Database.
*/
public static function adminGuard(): Database
{
require_once APP_ROOT . '/src/AdminAuth.php';
AdminAuth::requireLogin();
return self::boot();
}
// ── CSRF lifecycle ────────────────────────────────────────────────────────
/**
* Ensure a CSRF token exists in the session.
* Called automatically by boot() / adminGuard().
*/
private static function ensureCsrf(): void
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
}
/**
* Validate the CSRF token on a POST request.
* Halts with 403 if the token is missing or invalid.
*/
public static function verifyCsrf(): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|| !isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
http_response_code(403);
exit('CSRF token invalide.');
}
}
/**
* Regenerate the CSRF token after a successful mutation.
*/
public static function rotateCsrf(): void
{
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// ── Flash messages ────────────────────────────────────────────────────────
/**
* Store a flash message in the session.
*
* @param 'success'|'error' $type
*/
public static function flash(string $type, string $message): void
{
$_SESSION["_flash_{$type}"] = $message;
}
/**
* Store the name of the field that should receive autofocus after a
* validation failure (WCAG 3.3.1).
*/
public static function flashAutofocus(string $fieldName): void
{
$_SESSION['_flash_autofocus'] = $fieldName;
}
/**
* Consume and return the autofocus field name, then clear it.
* Returns null when no autofocus hint is present.
*/
public static function consumeAutofocus(): ?string
{
$field = $_SESSION['_flash_autofocus'] ?? null;
unset($_SESSION['_flash_autofocus']);
return $field;
}
/**
* Consume and return flash messages, then clear them from the session.
*
* @return array{error: ?string, success: ?string}
*/
public static function consumeFlash(): array
{
$error = $_SESSION['_flash_error'] ?? null;
$success = $_SESSION['_flash_success'] ?? null;
unset($_SESSION['_flash_error'], $_SESSION['_flash_success']);
// Note: autofocus is consumed separately via consumeAutofocus().
return ['error' => $error, 'success' => $success];
}
// ── Redirect ──────────────────────────────────────────────────────────────
/**
* Flash a message and redirect. Terminates the script.
*/
public static function redirect(string $url, ?string $success = null, ?string $error = null): never
{
if ($success !== null) {
self::flash('success', $success);
}
if ($error !== null) {
self::flash('error', $error);
}
header('Location: ' . $url);
exit;
}
// ── Asset versioning ─────────────────────────────────────────────────────
/**
* Return an asset URL with a filemtime-based cache-busting query string.
* Input is a root-relative URL path (e.g. /assets/css/main.css).
*/
public static function assetV(string $urlPath): string
{
$file = APP_ROOT . '/public' . $urlPath;
$v = file_exists($file) ? filemtime($file) : 0;
return $urlPath . ($v ? '?v=' . $v : '');
}
// ── Template rendering ────────────────────────────────────────────────────
/**
* Render a full page: head → header → content template → footer.
*
* Expects $vars to contain the same keys the templates already rely on
* ($pageTitle, $bodyClass, $isAdmin, $extraCss, $ogTags, etc.).
* The footer variant (public vs admin) is chosen automatically based
* on $isAdmin.
*
* @param string $template Path relative to APP_ROOT/templates/
* @param array $vars Variables to expose inside the templates
*/
public static function render(string $template, array $vars = []): void
{
// Make all vars available in the template scope.
extract($vars);
include APP_ROOT . '/templates/head.php';
include APP_ROOT . '/templates/header.php';
include APP_ROOT . '/templates/' . $template;
if (!empty($isAdmin)) {
include APP_ROOT . '/templates/admin/footer.php';
} else {
include APP_ROOT . '/templates/footer.php';
}
}
}

View File

@@ -0,0 +1,43 @@
<?php
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/Parsedown.php';
class AboutController {
private string $defaultContent = "Ce site POSTERG a été créé pour répertorier et valoriser les mémoires de l'erg École de Recherches Graphiques de Bruxelles.\n\nL'objectif est à la fois d'offrir une vitrine aux projets des anciennes étudiantes et de mettre en lumière la diversité des disciplines et des parcours qui façonnent l'histoire de l'école à travers les âges, depuis près de 100 ans.";
public static function create(): self { return new self(); }
public function handle(): array {
try {
$db = Database::getInstance();
$aboutPage = $db->getPage('about');
$rawContent = $aboutPage ? $aboutPage['content'] : '';
if (empty(trim($rawContent)) || trim($rawContent) === 'Contenu à venir') {
$rawContent = $this->defaultContent;
}
$contacts = $db->getAproposContent('contacts');
$credits = $db->getAproposContent('credits');
$contacts = is_array($contacts) && !empty($contacts) ? $contacts : null;
$credits = is_array($credits) && !empty($credits) ? $credits : null;
} catch (Exception $e) {
error_log("Error loading about page: " . $e->getMessage());
$rawContent = $this->defaultContent;
$contacts = null;
$credits = null;
}
$pd = new Parsedown();
$pd->setSafeMode(true);
return [
'nav' => 'apropos',
'aboutHtml' => $pd->text($rawContent),
'contacts' => $contacts,
'credits' => $credits,
'pageTitle' => 'À Propos Posterg',
'metaDescription' => "À propos de Posterg, le répertoire des mémoires de fin d'études de l'erg École de Recherches Graphiques de Bruxelles.",
'extraCss' => ['/assets/css/apropos.css'],
'bodyClass' => 'apropos-body',
];
}
}

View File

@@ -0,0 +1,166 @@
<?php
/**
* ExportController
*
* Centralises all export logic for admin-facing data dumps.
*
* Responsibilities:
* - Export the full SQLite database as a .sqlite file download
* - Export TFE listings as CSV (the reverse of the CSV import)
*
* The class has NO output side-effects; the thin dispatcher files
* (public/admin/actions/…) perform headers and echo.
*/
class ExportController
{
private Database $db;
public function __construct(Database $db)
{
$this->db = $db;
}
public static function create(): self
{
require_once APP_ROOT . '/src/Database.php';
return new self(Database::getInstance());
}
// ── Database export ──────────────────────────────────────────────────
/**
* Return the absolute path of the live database file.
*/
public function getDatabasePath(): string
{
return $this->db->getDatabasePath();
}
// ── CSV export ───────────────────────────────────────────────────────
/**
* Column headers matching the import format.
*/
public const CSV_HEADERS = [
'Identifiant',
'Titre',
'Sous-titre',
'Auteur·ice(s)',
'Contact',
'Promoteur·ice(s)',
'Format(s)',
'Année',
'AP',
'Orientation',
'Finalité',
'Mots-clés',
'Synopsis',
'Contexte',
'Remarques',
'Langue',
'Autorisation',
'Licence',
'Taille',
'Points sur 20',
'Lien BAIU',
];
/**
* Fetch all theses and their related data, then return a list of rows
* shaped to match the import CSV column order.
*
* Uses batch queries (one per related table) to avoid N+1.
*
* @return list<list<string>> Each inner list has CSV_HEADERS_COUNT elements.
*/
public function exportAllTheses(): array
{
// 1) Base thesis data
$theses = $this->db->getAllThesesForExport();
if ($theses === []) {
return [];
}
// 2) Load related data in batches
$byThesis = function (array $rows): array {
$map = [];
foreach ($rows as $r) {
$tid = (int) $r['thesis_id'];
$map[$tid][] = $r;
}
return $map;
};
$authors = $byThesis($this->db->getAllThesisAuthorsForExport());
$supervisors = $byThesis($this->db->getAllThesisSupervisorsForExport());
$tags = $byThesis($this->db->getAllThesisTagsForExport());
$languages = $byThesis($this->db->getAllThesisLanguagesForExport());
$formats = $byThesis($this->db->getAllThesisFormatsForExport());
// 3) Build CSV rows
$csvRows = [];
foreach ($theses as $t) {
$tid = (int) $t['id'];
// Authors + contact (first author with email)
$authorList = [];
$contact = '';
foreach (($authors[$tid] ?? []) as $a) {
$authorList[] = $a['name'];
if ($contact === '' && !empty($a['email'])) {
$contact = $a['email'];
}
}
// Supervisors
$supList = [];
foreach (($supervisors[$tid] ?? []) as $s) {
$supList[] = $s['name'];
}
// Tags
$tagList = [];
foreach (($tags[$tid] ?? []) as $tg) {
$tagList[] = $tg['name'];
}
// Languages
$langList = [];
foreach (($languages[$tid] ?? []) as $l) {
$langList[] = $l['name'];
}
// Formats
$fmtList = [];
foreach (($formats[$tid] ?? []) as $f) {
$fmtList[] = $f['name'];
}
$csvRows[] = [
$t['identifier'] ?? '',
$t['title'] ?? '',
$t['subtitle'] ?? '',
implode(', ', $authorList),
$contact,
implode(', ', $supList),
implode(', ', $fmtList),
$t['year'] ?? '',
$t['ap_program'] ?? '',
$t['orientation'] ?? '',
$t['finality_type'] ?? '',
implode(', ', $tagList),
$t['synopsis'] ?? '',
$t['context_note'] ?? '',
$t['remarks'] ?? '',
implode(', ', $langList),
$t['access_type'] ?? '',
$t['license_name'] ?? '',
$t['file_size_info'] ?? '',
isset($t['jury_points']) ? (string) $t['jury_points'] : '',
$t['baiu_link'] ?? '',
];
}
return $csvRows;
}
}

View File

@@ -0,0 +1,140 @@
<?php
/**
* HomeController
*
* Handles all data-fetching and view-variable assembly for the public home page
* (public/index.php).
*
* Responsibilities:
* - Parse and validate GET parameters (`page`, `year`)
* - Determine the display mode (default random-latest / year-filtered / paginated all)
* - Run the appropriate Database queries
* - Batch-load cover images for theses without a banner_path
* - Assemble OG / meta tag array
* - Return a flat array of view variables ready for template extraction
*
* The class has NO output side-effects; all template rendering stays in
* public/index.php so the view layer remains easy to inspect and modify.
*/
class HomeController
{
private const ITEMS_PER_PAGE = 24;
private Database $db;
public function __construct(Database $db)
{
$this->db = $db;
}
// ── Factory ───────────────────────────────────────────────────────────────
/**
* Convenience factory: loads the Database singleton and returns a ready
* controller instance.
*/
public static function create(): self
{
require_once APP_ROOT . '/src/Database.php';
return new self(Database::getInstance());
}
// ── Main entry point ─────────────────────────────────────────────────────
/**
* Process the current request and return all variables needed by the view.
*
* @return array<string, mixed>
*/
public function handle(): array
{
$page = isset($_GET['page']) ? max(1, (int) $_GET['page']) : 1;
$year = isset($_GET['year']) ? (int) $_GET['year'] : null;
// Normalise zero (e.g. ?year=0) to null so it is treated as "no filter"
if ($year === 0) {
$year = null;
}
// Default home view: random theses from latest year (no year filter, no explicit page)
$isDefaultView = ($year === null && $page === 1);
$itemsToLoad = [];
$totalItems = 0;
$availableYears = [];
$latestYear = null;
$coverMap = [];
try {
$offset = ($page - 1) * self::ITEMS_PER_PAGE;
$availableYears = $this->db->getAvailableYears();
if ($year !== null) {
$itemsToLoad = $this->db->searchTheses(['year' => $year], self::ITEMS_PER_PAGE, $offset);
$totalItems = $this->db->countSearchResults(['year' => $year]);
} elseif ($isDefaultView) {
$latestYear = $this->db->getLatestPublishedYear();
$itemsToLoad = $this->db->getLatestYearTheses(self::ITEMS_PER_PAGE);
$totalItems = count($itemsToLoad); // no multi-page on default view
} else {
$itemsToLoad = $this->db->getPublishedTheses(self::ITEMS_PER_PAGE, $offset);
$totalItems = $this->db->countPublishedTheses();
}
// Batch-load cover images for theses that have no banner_path
if (!empty($itemsToLoad)) {
$needCover = array_column(
array_filter($itemsToLoad, static fn($t) => empty($t['banner_path'])),
'id'
);
if (!empty($needCover)) {
$coverMap = $this->db->getCoverPathsForTheses($needCover);
}
}
} catch (Exception $e) {
error_log('HomeController: ' . $e->getMessage());
// Return safe empty state; view will show "Aucun mémoire trouvé"
$isDefaultView = false;
}
$totalPages = $isDefaultView ? 1 : (int) ceil($totalItems / self::ITEMS_PER_PAGE);
// Avoid division by zero on empty DB
if ($totalPages < 1) {
$totalPages = 0;
}
$baseParams = array_filter(['year' => $year]);
return [
// Pagination / filter state
'page' => $page,
'year' => $year,
'isDefaultView' => $isDefaultView,
'totalItems' => $totalItems,
'totalPages' => $totalPages,
'baseParams' => $baseParams,
// Thesis data
'itemsToLoad' => $itemsToLoad,
'latestYear' => $latestYear,
'availableYears' => $availableYears,
'coverMap' => $coverMap,
// Page meta
'pageTitle' => 'Posterg Mémoires de l\'ERG',
'metaDescription' => 'Posterg répertorie et valorise les mémoires de fin d\'études (TFE) de l\'erg École de Recherches Graphiques de Bruxelles.',
'ogTags' => [
'type' => 'website',
'title' => 'Posterg Mémoires de l\'ERG',
'description' => 'Posterg répertorie et valorise les mémoires de fin d\'études (TFE) de l\'erg École de Recherches Graphiques de Bruxelles.',
'url' => 'https://posterg.erg.be/',
'site_name' => 'Posterg ERG',
],
// Layout
'currentNav' => '',
'extraCss' => ['/assets/css/main.css'],
'bodyClass' => 'home-body',
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/Parsedown.php';
class LicenceController {
public static function create(): self {
return new self();
}
public function handle(): array {
try {
$db = Database::getInstance();
$dbPage = $db->getPage('licenses');
$content = $dbPage ? $dbPage['content'] : '';
$pageTitle = $dbPage ? $dbPage['title'] : 'Licences';
} catch (Exception $e) {
error_log("Error loading licence page: " . $e->getMessage());
$content = '';
$pageTitle = 'Licences';
}
$pd = new Parsedown();
$pd->setSafeMode(true);
$html = $pd->text($content);
return [
'content' => $content,
'html' => $html,
'pageTitle' => $pageTitle . ' Posterg',
'metaDescription' => "Informations sur les licences d'utilisation des mémoires publiés sur Posterg, le répertoire des TFE de l'erg.",
'currentNav' => 'licence',
'extraCss' => ['/assets/css/apropos.css'],
'bodyClass' => 'apropos-body',
];
}
}

View File

@@ -0,0 +1,57 @@
<?php
/**
* Live-reload endpoint for PHP built-in development server.
* Polls file mtimes across source directories and returns
* whether anything changed since last check.
*
* Usage (from browser): /live-reload
*/
class LiveReloadController {
private array $watchDirs;
private array $watchExts = ['php', 'css', 'js', 'html'];
private string $stateFile;
public function __construct(string $appRoot) {
$this->watchDirs = [
$appRoot . '/public',
$appRoot . '/src',
$appRoot . '/config',
$appRoot . '/templates',
];
$this->stateFile = sys_get_temp_dir() . '/posterg-live-reload.txt';
}
public function handle(): array {
return ['json' => true, 'body' => $this->poll()];
}
private function poll(): array {
$hash = '';
foreach ($this->watchDirs as $dir) {
if (!is_dir($dir)) continue;
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($it as $file) {
if (in_array($file->getExtension(), $this->watchExts, true)) {
$hash .= $file->getMTime() . '|' . $file->getPathname() . "\n";
}
}
}
$fingerprint = md5($hash);
$prev = file_exists($this->stateFile) ? file_get_contents($this->stateFile) : null;
if ($prev === null) {
file_put_contents($this->stateFile, $fingerprint);
$changed = false;
} else {
$changed = $fingerprint !== $prev;
if ($changed) {
file_put_contents($this->stateFile, $fingerprint);
}
}
return ['changed' => $changed];
}
}

View File

@@ -0,0 +1,114 @@
<?php
/**
* MediaController
*
* Serves uploaded files stored outside the webroot (STORAGE_ROOT).
* This is the sole access point for thesis files, covers, and annexes — they
* are never exposed as direct filesystem paths from the web server.
*
* Security:
* - Strict character whitelist on the path parameter (no path traversal)
* - realpath() jail: resolved path must stay inside STORAGE_ROOT
* - MIME type verified against an allow-list before serving
* - Access-type gate for thesis files (blocks 'Interdit' access_type_id=3)
*/
class MediaController
{
/**
* Handle a media request. Reads $_GET['path'], validates, and streams the file.
* Sends appropriate headers and exit() — no return value.
*/
public function handle(): void
{
$requestedPath = $_GET['path'] ?? '';
// 1. Validate path characters
if (!preg_match('#^[a-zA-Z0-9/_\-.]+$#', $requestedPath) || $requestedPath === '') {
http_response_code(400);
exit;
}
// 2. Resolve path + storage jail
$storageRoot = defined('STORAGE_ROOT') ? STORAGE_ROOT : '/var/www/posterg/storage';
$fullPath = $storageRoot . '/' . $requestedPath;
$realStorage = realpath($storageRoot);
$realFull = realpath($fullPath);
if (
$realFull === false
|| $realStorage === false
|| strpos($realFull, $realStorage . '/') !== 0
) {
http_response_code(404);
exit;
}
if (!is_file($realFull)) {
http_response_code(404);
exit;
}
// 3. Visibility gate for thesis files
if (preg_match('#^theses/#', $requestedPath)) {
require_once APP_ROOT . '/src/Database.php';
try {
$mediaDb = Database::getInstance();
$accessTypeId = $mediaDb->getFileVisibility($requestedPath);
if ($accessTypeId !== null && $accessTypeId === 3) {
http_response_code(403);
exit;
}
} catch (\Throwable $e) {
error_log("MediaController visibility check error: " . $e->getMessage());
}
}
// 4. Verify MIME type
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($realFull);
$allowedMimes = [
'image/jpeg',
'image/png',
'image/gif',
'application/pdf',
'video/mp4',
'application/zip',
'text/vtt', // WebVTT caption sidecar files
];
// finfo may return 'text/plain' for WebVTT files on some systems;
// re-classify by extension so we don't block them.
if ($mimeType === 'text/plain' && strtolower(pathinfo($realFull, PATHINFO_EXTENSION)) === 'vtt') {
$mimeType = 'text/vtt';
}
if (!in_array($mimeType, $allowedMimes, true)) {
http_response_code(403);
exit;
}
// 5. Send response headers
header('Content-Type: ' . $mimeType);
header('Content-Length: ' . filesize($realFull));
header('X-Content-Type-Options: nosniff');
$ext = strtolower(pathinfo($realFull, PATHINFO_EXTENSION));
if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif'], true)) {
header('Cache-Control: public, max-age=604800');
} elseif ($ext === 'pdf') {
header('Cache-Control: public, max-age=86400');
header('Content-Disposition: inline');
} elseif ($ext === 'vtt') {
header('Content-Type: text/vtt; charset=utf-8');
header('Cache-Control: public, max-age=86400');
} else {
header('Cache-Control: private, no-store');
}
// 6. Stream file
readfile($realFull);
}
}

View File

@@ -0,0 +1,319 @@
<?php
/**
* SearchController
*
* Handles all data-fetching logic for the public search and répertoire pages.
*
* Entry points:
* - public/search.php calls handleSearch() — text-query results
* - public/repertoire.php calls handleRepertoire() — filter index + HTMX swaps
*
* Responsibilities:
* - Rate-limit enforcement (returns early HTTP 429 response when needed)
* - GET parameter sanitisation and validation
* - Database queries (search + index listings)
* - OG / meta tag assembly
* - HTMX partial response for repertoire filter swaps
*
* The class has NO output side-effects; all template rendering stays in the
* respective public/*.php files so the view layer remains easy to inspect.
* Exception: renderRepertoirePartial() exits early for HTMX requests.
*/
class SearchController
{
private const RATE_LIMIT_MAX = 30;
private const RATE_LIMIT_WINDOW = 60; // seconds
private const ITEMS_PER_PAGE = 30;
private Database $db;
private RateLimit $rateLimit;
public function __construct(Database $db, RateLimit $rateLimit)
{
$this->db = $db;
$this->rateLimit = $rateLimit;
}
// ── Factory ───────────────────────────────────────────────────────────────
/**
* Convenience factory: builds dependencies, checks rate limit (sends 429
* and exits if exceeded), then returns a ready-to-use controller instance.
*/
public static function create(): self
{
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/RateLimit.php';
$rateLimit = new RateLimit(self::RATE_LIMIT_MAX, self::RATE_LIMIT_WINDOW);
if (!$rateLimit->check()) {
self::sendRateLimitResponse($rateLimit);
}
$rateLimit->sendHeaders();
// Probabilistic cleanup (1-in-100 requests) to prune stale entries
if (rand(1, 100) === 1) {
$rateLimit->cleanup();
}
return new self(Database::getInstance(), $rateLimit);
}
// ── Entry points ──────────────────────────────────────────────────────────
/**
* Handle the search results page (public/search.php).
* Requires a ?query= parameter; always returns search-result view variables.
*
* @return array<string, mixed>
*/
public function handleSearch(): array
{
$searchParams = $this->collectSearchParams();
$page = isset($_GET['page']) ? max(1, (int) $_GET['page']) : 1;
$offset = ($page - 1) * self::ITEMS_PER_PAGE;
$validationError = null;
$results = [];
$totalItems = 0;
$totalPages = 0;
$years = [];
$orientations = [];
$apPrograms = [];
try {
$results = $this->db->searchTheses($searchParams, self::ITEMS_PER_PAGE, $offset);
$totalItems = $this->db->countSearchResults($searchParams);
$totalPages = (int) ceil($totalItems / self::ITEMS_PER_PAGE);
$years = $this->db->getAvailableYears();
$orientations = $this->db->getAllOrientations();
$apPrograms = $this->db->getAllAPPrograms();
} catch (InvalidArgumentException $e) {
$validationError = $e->getMessage();
} catch (Exception $e) {
error_log('SearchController: ' . $e->getMessage());
$validationError = 'Une erreur est survenue.';
}
// Preserve all active params, strip 'page' (pagination partial adds it)
$baseParams = array_diff_key($_GET, ['page' => '']);
$query = $_GET['query'] ?? '';
return [
'searchParams' => $searchParams,
'page' => $page,
'totalItems' => $totalItems,
'totalPages' => $totalPages,
'results' => $results,
'validationError' => $validationError,
'baseParams' => $baseParams,
// Filter dropdowns
'years' => $years,
'orientations' => $orientations,
'apPrograms' => $apPrograms,
// Page meta
'searchBarValue' => $query,
'pageTitle' => $query !== '' ? 'Recherche : ' . $query . ' Posterg' : 'Recherche Posterg',
'metaDescription' => "Résultats de recherche dans le répertoire des TFE de l'erg.",
'ogTags' => [
'type' => 'website',
'title' => 'Recherche Posterg',
'description' => "Résultats de recherche dans le répertoire des TFE de l'erg.",
'url' => 'https://posterg.erg.be/search.php',
'site_name' => 'Posterg ERG',
],
'currentNav' => 'repertoire',
'extraCss' => ['/assets/css/search.css'],
'bodyClass' => 'search-body',
];
}
/**
* Handle the répertoire index page (public/repertoire.php).
* Serves the filter-column index; HTMX partial swaps are handled here too.
*
* @return array<string, mixed>
*/
public function handleRepertoire(): array
{
$isHtmx = !empty($_SERVER['HTTP_HX_REQUEST']);
$activeFilters = $this->collectFilterParams();
$repData = null;
$validationError = null;
try {
$repData = $this->db->getRepertoireFilterData($activeFilters);
} catch (InvalidArgumentException $e) {
$validationError = $e->getMessage();
} catch (Exception $e) {
error_log('SearchController: ' . $e->getMessage());
$validationError = 'Une erreur est survenue.';
}
// HTMX partial: render just the index div and exit
if ($isHtmx && $repData !== null) {
$this->renderRepertoirePartial($repData, $activeFilters);
}
return [
'repData' => $repData,
'activeFilters' => $activeFilters,
'isHtmx' => $isHtmx,
'validationError' => $validationError,
// Page meta
'searchBarValue' => '',
'pageTitle' => 'Répertoire Posterg',
'metaDescription' => "Parcourez le répertoire des mémoires de fin d'études (TFE) de l'erg École de Recherches Graphiques de Bruxelles.",
'ogTags' => [
'type' => 'website',
'title' => 'Répertoire Posterg',
'description' => "Parcourez le répertoire des mémoires de fin d'études (TFE) de l'erg École de Recherches Graphiques de Bruxelles.",
'url' => 'https://posterg.erg.be/repertoire.php',
'site_name' => 'Posterg ERG',
],
'currentNav' => 'repertoire',
'extraCss' => ['/assets/css/search.css'],
'bodyClass' => 'search-body',
];
}
// ── Private helpers ───────────────────────────────────────────────────────
/**
* Render the repertoire index partial and exit (for HTMX swaps).
* Never returns.
*/
private function renderRepertoirePartial(array $repData, array $activeFilters): never
{
header('Content-Type: text/html; charset=UTF-8');
$isHtmx = true;
include APP_ROOT . '/templates/partials/repertoire-index.php';
exit;
}
/**
* Collect and sanitise repertoire filter params from $_GET.
* Params: fy[] (years), ap[] (AP programs), or[] (orientations),
* fi[] (finality types), kw[] (keywords)
*
* @return array{years:int[], ap:string[], or:string[], fi:string[], kw:string[]}
*/
private function collectFilterParams(): array
{
$sanitiseStrings = function(mixed $raw, int $maxLen = 100): array {
if (!is_array($raw)) return [];
$out = [];
foreach ($raw as $v) {
$v = trim((string)$v);
if ($v !== '' && strlen($v) <= $maxLen) {
$out[] = $v;
}
}
return array_values(array_unique($out));
};
$years = [];
if (!empty($_GET['fy']) && is_array($_GET['fy'])) {
foreach ($_GET['fy'] as $y) {
$y = (int)$y;
if ($y >= 1900 && $y <= 2100) $years[] = $y;
}
$years = array_values(array_unique($years));
}
return [
'years' => $years,
'ap' => $sanitiseStrings($_GET['ap'] ?? []),
'or' => $sanitiseStrings($_GET['or'] ?? []),
'fi' => $sanitiseStrings($_GET['fi'] ?? []),
'kw' => $sanitiseStrings($_GET['kw'] ?? []),
];
}
/**
* Sanitise and collect valid text search parameters from $_GET.
*
* @return array<string, mixed>
*/
private function collectSearchParams(): array
{
$params = [];
if (!empty($_GET['query'])) {
$params['query'] = trim((string) $_GET['query']);
}
if (!empty($_GET['year'])) {
$params['year'] = (int) $_GET['year'];
}
if (!empty($_GET['orientation'])) {
$params['orientation'] = (string) $_GET['orientation'];
}
if (!empty($_GET['ap_program'])) {
$params['ap_program'] = (string) $_GET['ap_program'];
}
if (!empty($_GET['keyword'])) {
$params['keyword'] = (string) $_GET['keyword'];
}
return $params;
}
// ── Rate-limit response ───────────────────────────────────────────────────
/**
* Send a 429 response and exit. Never returns.
*/
private static function sendRateLimitResponse(RateLimit $rateLimit): never
{
http_response_code(429);
header('Retry-After: ' . $rateLimit->getResetTime());
$retrySeconds = (int) $rateLimit->getResetTime();
echo <<<HTML
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Trop de requêtes Posterg</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #0d0d0d;
color: #e0e0e0;
font-family: 'Helvetica Neue', Arial, sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.box { max-width: 520px; text-align: center; }
.box__logo {
font-size: 1.1rem; font-weight: 700;
letter-spacing: .12em; text-transform: uppercase;
color: #fff; margin-bottom: 2.5rem;
}
.box__title { font-size: 1.6rem; font-weight: 300; margin-bottom: 1rem; }
.box__text { font-size: .95rem; color: #999; line-height: 1.7; }
</style>
</head>
<body>
<div class="box">
<div class="box__logo">POSTERG</div>
<h1 class="box__title">Trop de requêtes</h1>
<p class="box__text">Vous avez effectué trop de recherches en peu de temps.<br>
Réessayez dans {$retrySeconds} secondes.</p>
</div>
</body>
</html>
HTML;
exit;
}
}

View File

@@ -0,0 +1,456 @@
<?php
/**
* SystemController
*
* Centralises all data-fetching for the admin system page and its
* fetch()-based tab-panel fragment endpoint.
*
* Responsibilities:
* - System status checks (nginx, php-fpm, HTTP ping, database, storage,
* maintenance mode) with SystemCache TTL caching
* - PHP environment info (1-hour TTL)
* - Disk usage info (5-minute TTL)
* - Log file reading (tail, meta)
* - Nginx config file reading
* - Log/nginx line classifiers used by both system.php and system-fragment.php
*
* Both system.php (full page) and system-fragment.php (AJAX panel) delegate
* here so helpers are never duplicated.
*/
class SystemController
{
// ── Constants ─────────────────────────────────────────────────────────────
public const LOG_FILES = [
'nginx_access' => ['label' => 'nginx — accès', 'path' => '/var/log/nginx/posterg_access.log'],
'nginx_error' => ['label' => 'nginx — erreurs', 'path' => '/var/log/nginx/posterg_error.log'],
'php_error' => ['label' => 'PHP-FPM — erreurs', 'path' => '/var/log/php8.4-fpm.log'],
];
public const ALLOWED_LINES = [50, 100, 200, 500];
/** Live deployed nginx config path. */
public const NGINX_CONFIG_LIVE = '/etc/nginx/sites-available/posterg';
/** Local reference copy used as fallback in dev. */
public const NGINX_CONFIG_LOCAL = APP_ROOT . '/nginx/posterg.conf';
// ── TTLs ──────────────────────────────────────────────────────────────────
private const TTL_STATUS = 120; // 2 minutes
private const TTL_PHP = 3600; // 1 hour
private const TTL_DISK = 300; // 5 minutes
private Database $db;
private SystemCache $cache;
public function __construct(Database $db, SystemCache $cache)
{
$this->db = $db;
$this->cache = $cache;
}
// ── Cache invalidation ────────────────────────────────────────────────────
/**
* Force-bust all cached sections (called on ?refresh=1).
*/
public function invalidateAll(): void
{
$this->cache->invalidate('system_status');
$this->cache->invalidate('disk_info');
$this->cache->invalidate('php_info');
}
// ── Status data ───────────────────────────────────────────────────────────
/**
* Return system status checks array, from cache when fresh.
*
* @return array{checks: array, cached: bool, cacheAge: ?int}
*/
public function getStatusData(): array
{
$cacheAge = $this->cache->ageSeconds('system_status');
$cached = $this->cache->get('system_status', self::TTL_STATUS);
if ($cached !== null) {
return ['checks' => $cached, 'cached' => true, 'cacheAge' => $cacheAge];
}
$checks = $this->runStatusChecks();
$this->cache->set('system_status', $checks);
return ['checks' => $checks, 'cached' => false, 'cacheAge' => 0];
}
/**
* Return PHP environment info, from cache when fresh.
*
* @return array<string, string>
*/
public function getPhpInfo(): array
{
$cached = $this->cache->get('php_info', self::TTL_PHP);
if ($cached !== null) {
return $cached;
}
$info = [
'version' => PHP_VERSION,
'sapi' => PHP_SAPI,
'memory_limit' => ini_get('memory_limit'),
'upload_max' => ini_get('upload_max_filesize'),
'post_max' => ini_get('post_max_size'),
'max_exec' => ini_get('max_execution_time') . 's',
];
$this->cache->set('php_info', $info);
return $info;
}
/**
* Return disk usage info, from cache when fresh.
*
* @return array{total: int, free: int, used: int, pct: int}
*/
public function getDiskInfo(): array
{
$cached = $this->cache->get('disk_info', self::TTL_DISK);
if ($cached !== null) {
return $cached;
}
$total = (int) disk_total_space(APP_ROOT);
$free = (int) disk_free_space(APP_ROOT);
$used = $total - $free;
$pct = $total > 0 ? (int) round($used / $total * 100) : 0;
$info = ['total' => $total, 'free' => $free, 'used' => $used, 'pct' => $pct];
$this->cache->set('disk_info', $info);
return $info;
}
// ── Log tab ───────────────────────────────────────────────────────────────
/**
* Read and return data for a log tab.
*
* @return array{lines: ?array, error: ?string, meta: ?array}
*/
public function getLogData(string $tab, int $n): array
{
$logPath = self::LOG_FILES[$tab]['path'];
$error = null;
$lines = $this->readLogTail($logPath, $n, $error);
$meta = null;
if (file_exists($logPath)) {
$sz = filesize($logPath);
$meta = [
'size' => $sz > 1048576
? number_format($sz / 1048576, 2) . ' MB'
: number_format($sz / 1024, 1) . ' KB',
'mtime' => date('d/m/Y H:i:s', filemtime($logPath)),
'path' => $logPath,
];
}
return ['lines' => $lines, 'error' => $error, 'meta' => $meta];
}
// ── Nginx config tab ──────────────────────────────────────────────────────
/**
* Read and return data for the nginx config tab.
*
* @return array{lines: ?array, source: ?string, meta: ?array, error: ?string}
*/
public function getNginxConfigData(): array
{
$livePath = self::NGINX_CONFIG_LIVE;
$localPath = self::NGINX_CONFIG_LOCAL;
foreach ([[$livePath, 'live'], [$localPath, 'local']] as [$path, $src]) {
if (file_exists($path) && is_readable($path)) {
$raw = file($path, FILE_IGNORE_NEW_LINES);
if ($raw !== false) {
$sz = filesize($path);
$meta = [
'path' => $path,
'size' => $sz > 1048576
? number_format($sz / 1048576, 2) . ' MB'
: number_format($sz / 1024, 1) . ' KB',
'mtime' => date('d/m/Y H:i:s', filemtime($path)),
];
return ['lines' => $raw, 'source' => $src, 'meta' => $meta, 'error' => null];
}
}
}
$error = file_exists($livePath)
? "Fichier non lisible (permissions insuffisantes) : " . htmlspecialchars($livePath)
: "Config live introuvable (" . htmlspecialchars($livePath) . ") et config locale introuvable (" . htmlspecialchars($localPath) . ").";
return ['lines' => null, 'source' => null, 'meta' => null, 'error' => $error];
}
// ── Line classifiers (used by both system.php and system-fragment.php) ────
/**
* Return the CSS class for a log line.
*/
public static function logLineClass(string $line): string
{
if (preg_match('/\[(crit|emerg|alert)\]/', $line)) return 'log-crit';
if (preg_match('/\[error\]/', $line)) return 'log-error';
if (preg_match('/\[warn\]/', $line)) return 'log-warn';
if (preg_match('/\[notice\]/', $line)) return 'log-notice';
if (preg_match('/" [45]\d\d /', $line)) return 'log-error';
if (preg_match('/" 3\d\d /', $line)) return 'log-notice';
return '';
}
/**
* Return the CSS class for a nginx config line.
*/
public static function nginxLineClass(string $line): string
{
$trimmed = ltrim($line);
if ($trimmed === '' || str_starts_with($trimmed, '#')) return 'nginx-comment';
if (preg_match('/^\s*(location|server|upstream|events|http|geo|map|types)\b/', $line)) return 'nginx-block';
return 'nginx-directive';
}
// ── View helpers ──────────────────────────────────────────────────────────
/**
* Human-readable byte string (GB / MB / KB).
*/
public static function humanBytes(int $bytes): string
{
if ($bytes > 1073741824) return number_format($bytes / 1073741824, 1) . ' GB';
if ($bytes > 1048576) return number_format($bytes / 1048576, 1) . ' MB';
return number_format($bytes / 1024, 1) . ' KB';
}
/**
* French status label with leading symbol.
*/
public static function statusLabel(string $status): string
{
return match ($status) {
'active' => '● En ligne',
'inactive' => '○ Inactif',
'failed' => '✕ Erreur',
'warn' => '⚠ Attention',
default => '? Inconnu',
};
}
/**
* CSS class for a status value.
*/
public static function statusClass(string $status): string
{
return match ($status) {
'active' => 'status-ok',
'inactive' => 'status-warn',
'warn' => 'status-warn',
'failed' => 'status-err',
default => 'status-unknown',
};
}
/**
* CSS colour string for a disk-usage percentage.
*/
public static function diskColor(int $pct): string
{
if ($pct > 85) return '#e05555';
if ($pct > 70) return '#ffc107';
return '#4caf50';
}
// ── Private helpers ───────────────────────────────────────────────────────
/**
* Execute all six status checks and return the checks array.
*/
private function runStatusChecks(): array
{
$checks = [];
// nginx
$nginxStatus = $this->systemdStatus('nginx');
$nginxVersion = $this->safeExec('nginx -v 2>&1 | head -1');
$checks['nginx'] = [
'label' => 'nginx',
'status' => $nginxStatus,
'detail' => $nginxVersion,
];
// php-fpm — probe running PHP version's unit first, then fall back
$phpFpmStatus = null;
$phpFpmUnit = null;
$phpMajMin = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION;
$fpmCandidates = array_unique([
'php' . $phpMajMin . '-fpm',
'php8.4-fpm', 'php8.3-fpm', 'php8.2-fpm', 'php8.1-fpm', 'php-fpm',
]);
foreach ($fpmCandidates as $unit) {
$s = $this->systemdStatus($unit);
if ($s !== null && $s !== 'unknown') {
$phpFpmStatus = $s;
$phpFpmUnit = $unit;
break;
}
}
$checks['php_fpm'] = [
'label' => 'php-fpm' . ($phpFpmUnit ? " ($phpFpmUnit)" : ''),
'status' => $phpFpmStatus,
'detail' => null,
];
// Site HTTP ping
$siteUrl = (isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . '/';
$httpResult = $this->localHttpCheck($siteUrl);
$checks['site_http'] = [
'label' => 'Site HTTP',
'status' => $httpResult !== null ? ($httpResult[0] < 500 ? 'active' : 'failed') : null,
'detail' => $httpResult !== null
? "HTTP {$httpResult[0]}{$httpResult[1]} ms"
: 'curl indisponible',
];
// Database
$dbPath = $this->db->getDatabasePath();
$dbExists = file_exists($dbPath);
$dbWritable = $dbExists && is_writable($dbPath);
$dbSizeBytes = $dbExists ? filesize($dbPath) : null;
$dbSizeHuman = $dbSizeBytes !== null
? ($dbSizeBytes > 1048576
? number_format($dbSizeBytes / 1048576, 1) . ' MB'
: number_format($dbSizeBytes / 1024, 1) . ' KB')
: 'N/A';
$dbRowCount = null;
if ($dbExists) {
try {
$dbRowCount = $this->db->getThesisCount();
} catch (Throwable) {}
}
$checks['database'] = [
'label' => 'Base de données SQLite',
'status' => $dbExists ? ($dbWritable ? 'active' : 'inactive') : 'failed',
'detail' => $dbExists
? ($dbRowCount !== null ? "$dbRowCount thèses — $dbSizeHuman" : "Lecture impossible — $dbSizeHuman")
: 'Fichier introuvable',
];
// Storage directory
$storageDir = APP_ROOT . '/storage';
$storageWritable = is_dir($storageDir) && is_writable($storageDir);
$bannersDir = $storageDir . '/banners';
$coversDir = $storageDir . '/covers';
$checks['storage'] = [
'label' => 'Répertoire storage',
'status' => $storageWritable ? 'active' : (is_dir($storageDir) ? 'inactive' : 'failed'),
'detail' => $storageWritable
? implode(' · ', array_filter([
is_dir($bannersDir) ? ('banners/ ' . count(array_diff(scandir($bannersDir), ['.','..'])) . ' fichiers') : null,
is_dir($coversDir) ? ('covers/ ' . count(array_diff(scandir($coversDir), ['.','..'])) . ' fichiers') : null,
]))
: 'Non accessible en écriture',
];
// Maintenance mode
$maintenanceOn = file_exists(APP_ROOT . '/storage/maintenance.flag');
$checks['maintenance'] = [
'label' => 'Mode maintenance',
'status' => $maintenanceOn ? 'warn' : 'active',
'detail' => $maintenanceOn ? 'Activé — site public inaccessible' : 'Désactivé',
];
return $checks;
}
/**
* Read the tail of a log file, newest-first. Returns null on error.
*/
private function readLogTail(string $logPath, int $n, ?string &$errorMsg): ?array
{
$errorMsg = null;
if (!function_exists('exec')) {
$errorMsg = "exec() est désactivé sur ce serveur.";
return null;
}
if (!file_exists($logPath)) {
$errorMsg = "Fichier introuvable : " . htmlspecialchars($logPath);
return null;
}
if (!is_readable($logPath)) {
$errorMsg = "Fichier non lisible (permissions insuffisantes) : " . htmlspecialchars($logPath);
return null;
}
$output = [];
$rc = 0;
exec('tail -n ' . intval($n) . ' ' . escapeshellarg($logPath) . ' 2>/dev/null', $output, $rc);
if ($rc !== 0) {
$errorMsg = "Erreur lors de la lecture du fichier journal.";
return null;
}
return array_reverse($output); // newest first
}
/**
* Run a shell command safely, returning trimmed stdout or null on failure.
*/
private function safeExec(string $cmd): ?string
{
if (!function_exists('exec')) return null;
$output = [];
$rc = 0;
exec($cmd . ' 2>/dev/null', $output, $rc);
return $rc === 0 ? trim(implode("\n", $output)) : null;
}
/**
* Query systemd for a unit's active state.
*/
private function systemdStatus(string $unit): ?string
{
$raw = $this->safeExec('systemctl is-active ' . escapeshellarg($unit));
if ($raw === null) return null;
return in_array($raw, ['active', 'inactive', 'failed', 'activating', 'deactivating'], true)
? $raw : 'unknown';
}
/**
* Perform a lightweight HEAD request to $url and return [httpCode, ms].
* Returns null if curl is unavailable.
*/
private function localHttpCheck(string $url): ?array
{
if (!function_exists('curl_init')) return null;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_NOBODY => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => 0,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
]);
$start = microtime(true);
curl_exec($ch);
$ms = (int) round((microtime(true) - $start) * 1000);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
return $code > 0 ? [$code, $ms] : null;
}
}

View File

@@ -0,0 +1,247 @@
<?php
/**
* TfeController
*
* Handles all data-fetching and view-variable assembly for the public TFE
* detail page (public/tfe.php).
*
* Responsibilities:
* - Validate the `id` GET parameter and load the thesis record
* - Enforce publication visibility (redirect to index on 404)
* - Resolve the OG image (banner → first image file)
* - Build the complete OG / Twitter Card tag array
* - Assemble the meta description from the synopsis
* - Collect WebVTT caption file paths for video pairing
* - Return a flat array of view variables ready for template extraction
*
* The class has NO output side-effects; all template rendering stays in
* public/tfe.php so the view layer remains easy to inspect and modify.
*/
class TfeController
{
private const BASE_URL = 'https://posterg.erg.be';
private const META_MAX_LEN = 160;
private Database $db;
public function __construct(Database $db)
{
$this->db = $db;
}
// ── Factory ───────────────────────────────────────────────────────────────
/**
* Convenience factory: loads the Database singleton and returns a ready
* controller instance.
*/
public static function create(): self
{
require_once APP_ROOT . '/src/Database.php';
return new self(Database::getInstance());
}
// ── Main entry point ─────────────────────────────────────────────────────
/**
* Process the current request.
*
* On success returns an array of view variables.
* On failure (missing id, thesis not found) sends a redirect and exits.
*
* @return array<string, mixed>
*/
public function handle(): array
{
$thesisId = isset($_GET['id']) ? (int) $_GET['id'] : 0;
if ($thesisId <= 0) {
$this->redirectHome();
}
try {
$data = $this->db->getThesisById($thesisId);
} catch (Exception $e) {
error_log('TfeController: ' . $e->getMessage());
$this->redirectHome();
}
if (empty($data)) {
$this->redirectHome();
}
// Access type (1 = open, 2 = restricted, 3 = forbidden)
$accessTypeId = $this->db->getThesisAccessTypeId($thesisId) ?? 1;
$isInterdit = ($accessTypeId === 3);
// Caption (WebVTT) files — N-th VTT is paired with the N-th <video>
$captionFiles = $this->collectCaptionPaths($data['files'] ?? []);
// Jury members with interne/externe split
$jury = $this->db->getThesisJury($thesisId);
$juryByRole = $this->splitJuryByRole($jury);
// Page meta
$metaDescription = $this->buildMetaDescription($data['synopsis'] ?? '');
$ogTags = $this->buildOgTags($data, $thesisId, $metaDescription);
$pageTitle = $data['title']
. (!empty($data['authors']) ? ' ' . $data['authors'] : '')
. ' Posterg';
return [
// Core data
'thesisId' => $thesisId,
'data' => $data,
'accessTypeId' => $accessTypeId,
'isInterdit' => $isInterdit,
'captionFiles' => $captionFiles,
'juryPresidents' => $juryByRole['presidents'],
'promoteursInternes' => $juryByRole['internes'],
'promoteursExternes' => $juryByRole['externes'],
'juryLecteurs' => $juryByRole['lecteurs'],
// Page meta
'pageTitle' => $pageTitle,
'metaDescription' => $metaDescription,
'ogTags' => $ogTags,
// Layout
'currentNav' => '',
'extraCss' => ['/assets/css/tfe.css'],
'bodyClass' => 'tfe-body',
];
}
// ── Private helpers ───────────────────────────────────────────────────────
/**
* Build a ~160-character meta description from the thesis synopsis.
*/
private function buildMetaDescription(string $synopsis): string
{
$plain = strip_tags($synopsis);
if (empty($plain)) {
return 'Mémoire de fin d\'études Posterg, répertoire des TFE de l\'erg.';
}
return strlen($plain) > self::META_MAX_LEN
? substr($plain, 0, self::META_MAX_LEN - 3) . '…'
: $plain;
}
/**
* Resolve the OG image URL: banner_path → first image file → empty string.
*
* @param array<int, array<string, mixed>> $files
*/
private function resolveOgImage(array $files, ?string $bannerPath): string
{
if (!empty($bannerPath)) {
return self::BASE_URL . '/media.php?path=' . rawurlencode($bannerPath);
}
foreach ($files as $file) {
$ext = strtolower(pathinfo($file['file_path'] ?? '', PATHINFO_EXTENSION));
if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'], true)) {
return self::BASE_URL . '/media.php?path=' . rawurlencode($file['file_path']);
}
}
return '';
}
/**
* Build the complete $ogTags array consumed by templates/head.php.
*
* @param array<string, mixed> $data
* @return array<string, string>
*/
private function buildOgTags(array $data, int $thesisId, string $metaDescription): array
{
$ogImage = $this->resolveOgImage($data['files'] ?? [], $data['banner_path'] ?? null);
$title = $data['title'] . (!empty($data['authors']) ? ' ' . $data['authors'] : '');
$imageAlt = $data['title'] . (!empty($data['authors']) ? ' par ' . $data['authors'] : '');
return [
'type' => 'article',
'title' => $title,
'description' => $metaDescription,
'url' => self::BASE_URL . '/tfe.php?id=' . $thesisId,
'image' => $ogImage,
'image_alt' => $imageAlt,
'site_name' => 'Posterg ERG',
'article_author' => $data['authors'] ?? '',
'article_published_time' => !empty($data['year']) ? $data['year'] . '-01-01' : '',
];
}
/**
* Split jury members by role and internal/external flag.
*
* @param array<int, array<string, mixed>> $jury
* @return array{presidents: list<string>, internes: list<string>, externes: list<string>, lecteurs: list<string>}
*/
private function splitJuryByRole(array $jury): array
{
$result = ['presidents' => [], 'internes' => [], 'externes' => [], 'lecteurs' => []];
foreach ($jury as $member) {
$name = $member['name'] ?? '';
if ($name === '') continue;
switch ($member['role']) {
case 'president':
$result['presidents'][] = $name;
break;
case 'promoteur':
if ((int)$member['is_external'] === 1) {
$result['externes'][] = $name;
} else {
$result['internes'][] = $name;
}
break;
case 'lecteur':
$result['lecteurs'][] = $name;
break;
}
}
return $result;
}
/**
* Return an ordered list of VTT caption file paths from the files array.
* The N-th entry corresponds to the N-th <video> element in document order.
*
* @param array<int, array<string, mixed>> $files
* @return list<string>
*/
private function collectCaptionPaths(array $files): array
{
$captions = [];
foreach ($files as $file) {
$mime = $file['mime_type'] ?? '';
$ext = strtolower(pathinfo($file['file_path'] ?? '', PATHINFO_EXTENSION));
if ($mime === 'text/vtt' || $ext === 'vtt') {
$captions[] = $file['file_path'];
}
}
return $captions;
}
// ── Response helpers ──────────────────────────────────────────────────────
/**
* Redirect to the home page and terminate. Never returns.
*/
private function redirectHome(): never
{
header('Location: index.php');
exit;
}
}

View File

@@ -0,0 +1,444 @@
<?php
/**
* ThesisCreateController
*
* Centralises all validation, data-fetching, and persistence logic for the
* admin "add new thesis" workflow (admin/add.php + admin/actions/formulaire.php).
*
* Responsibilities:
* - Loading lookup tables for the add-form view (loadFormData)
* - Validating and sanitising POST submissions
* - Creating the thesis record, linking authors / jury / languages / formats /
* tags in a single database transaction
* - Handling cover image, banner, and multi-file uploads
* - WCAG 3.3.1: mapping validation exception messages to autofocus field hints
*
* The class has NO output side-effects; all redirects, flash writes, session
* mutations, and template rendering stay in the thin dispatcher files so the
* view layer remains easy to inspect and modify.
*/
class ThesisCreateController
{
/** Maximum allowed file size for thesis files (bytes). */
private const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
/** MIME types accepted for thesis files. */
private const ALLOWED_MIME_TYPES = [
'image/jpeg', 'image/png', 'application/pdf',
'video/mp4', 'application/zip', 'text/vtt',
];
/** File extensions accepted for thesis files. */
private const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'pdf', 'mp4', 'zip', 'vtt'];
private Database $db;
public function __construct(Database $db)
{
$this->db = $db;
}
// ── Factory ───────────────────────────────────────────────────────────────
/**
* Convenience factory — instantiates Database and returns a ready
* controller instance.
*/
public static function make(): self
{
require_once APP_ROOT . '/src/Database.php';
return new self(new Database());
}
// ── Read / view data ─────────────────────────────────────────────────────
/**
* Load all lookup tables required to render the add-thesis form.
*
* Returns a flat array of view variables:
* - 'orientations' orientation lookup rows
* - 'apPrograms' AP program lookup rows
* - 'finalityTypes' finality type lookup rows
* - 'languages' language lookup rows
* - 'formatTypes' format type lookup rows
* - 'licenseTypes' license type lookup rows
*
* @return array<string, mixed>
* @throws Exception on DB error.
*/
public function loadFormData(): array
{
return [
'orientations' => $this->db->getAllOrientations(),
'apPrograms' => $this->db->getAllAPPrograms(),
'finalityTypes' => $this->db->getAllFinalityTypes(),
'languages' => $this->db->getAllLanguages(),
'formatTypes' => $this->db->getAllFormatTypes(),
'licenseTypes' => $this->db->getAllLicenseTypes(),
'enabledAccessTypes' => $this->db->getEnabledFormAccessTypes(),
];
}
// ── Write / action ────────────────────────────────────────────────────────
/**
* Validate and persist a new-thesis POST submission.
*
* On success, returns the new thesis ID so the caller can redirect to
* thanks.php?id=<n>. On validation or DB failure, throws an Exception
* (caller must flash the message and redirect back to the form).
*
* Execution order:
* 1. Validate + sanitise POST fields
* 2. Find/create author record
* 3. INSERT thesis row + link author (inside transaction)
* 4. Link jury, languages, formats, tags (inside transaction)
* 5. COMMIT
* 6. Handle file uploads: cover, banner, thesis files (outside transaction)
*
* @param array $post Sanitised $_POST array.
* @param array $files $_FILES array.
* @return int The newly created thesis ID.
* @throws Exception On validation or DB error.
*/
public function submit(array $post, array $files): int
{
// ── 1. Validate + sanitise ────────────────────────────────────────────
$data = $this->validateAndSanitise($post);
// ── 2. Find / create author ───────────────────────────────────────────
$authorId = $this->db->findOrCreateAuthor($data['auteurName'], $data['mail'] ?: null, $data['showContact']);
error_log("ThesisCreateController: author ID $authorId");
// ── 34. DB writes in a transaction ───────────────────────────────────
$this->db->beginTransaction();
try {
$thesisId = $this->db->createThesis([
'year' => $data['annee'],
'orientation_id' => $data['orientationId'],
'ap_program_id' => $data['apProgramId'],
'finality_id' => $data['finalityId'],
'title' => $data['titre'],
'subtitle' => $data['subtitle'],
'synopsis' => $data['synopsis'],
'file_size_info' => $data['durationInfo'],
'baiu_link' => $data['lien'],
'license_id' => $data['licenseId'],
'access_type_id' => $data['accessTypeId'],
'author_id' => $authorId,
]);
$identifier = $this->db->getThesisIdentifier($thesisId);
error_log("ThesisCreateController: created thesis #$thesisId ($identifier)");
$this->db->setThesisJury($thesisId, $data['juryMembers']);
$this->db->setThesisLanguages($thesisId, $data['languageIds']);
$this->db->setThesisFormats($thesisId, $data['formatIds']);
$this->db->setThesisTags($thesisId, $data['keywords']);
$this->db->commit();
} catch (Exception $e) {
$this->db->rollback();
throw $e;
}
// ── 5. File uploads (outside transaction — filesystem ops) ────────────
$this->handleCoverUpload($thesisId, $files['couverture'] ?? null);
$this->db->handleBannerUpload($thesisId, $files['banner'] ?? null);
$this->handleThesisFiles($thesisId, $data['annee'], $identifier, $files['files'] ?? null);
return $thesisId;
}
// ── WCAG 3.3.1 helper ─────────────────────────────────────────────────────
/**
* Map a validation exception message to the name of the field that should
* receive autofocus when the form is re-rendered after an error.
*
* Returns null when no field mapping is found.
*/
public static function autofocusFieldForError(string $message): ?string
{
if (str_contains($message, 'Nom/Prénom/Pseudo')) return 'auteurice';
if (str_contains($message, 'Titre du mémoire')) return 'titre';
if (str_contains($message, 'Synopsis')) return 'synopsis';
if (str_contains($message, 'Année invalide')) return 'année';
if (str_contains($message, 'orientation')) return 'orientation';
if (str_contains($message, 'Atelier Pratique')) return 'ap';
if (str_contains($message, 'finalité')) return 'finality';
if (str_contains($message, 'langue')) return 'languages';
if (str_contains($message, 'mots-clés')) return 'tag';
if (str_contains($message, 'Lien URL')) return 'lien';
return null;
}
// ── Private: validation ───────────────────────────────────────────────────
/**
* Validate and sanitise all POST fields for a new thesis submission.
*
* Returns a flat associative array of clean values.
*
* @param array $post Raw $_POST.
* @return array<string, mixed>
* @throws Exception on validation failure.
*/
private function validateAndSanitise(array $post): array
{
$auteurName = $this->validateRequired(
$this->sanitiseString($post['auteurice'] ?? ''),
'Nom/Prénom/Pseudo'
);
$mail = !empty($post['mail']) ? $this->sanitiseString($post['mail']) : '';
$showContact = !empty($post['contact_public']) ? true : false;
$annee = filter_var($post['année'] ?? '', FILTER_VALIDATE_INT);
if ($annee === false || $annee < 2000 || $annee > ((int) date('Y') + 1)) {
throw new Exception('Année invalide. Veuillez entrer une année valide.');
}
$orientationId = filter_var($post['orientation'] ?? '', FILTER_VALIDATE_INT);
if ($orientationId === false) {
throw new Exception('Veuillez sélectionner une orientation.');
}
$apProgramId = filter_var($post['ap'] ?? '', FILTER_VALIDATE_INT);
if ($apProgramId === false) {
throw new Exception('Veuillez sélectionner un Atelier Pratique.');
}
$finalityId = filter_var($post['finality'] ?? '', FILTER_VALIDATE_INT);
if ($finalityId === false) {
throw new Exception('Veuillez sélectionner une finalité.');
}
$titre = $this->validateRequired($this->sanitiseString($post['titre'] ?? ''), 'Titre du mémoire');
$subtitle = $this->sanitiseString($post['subtitle'] ?? '');
$synopsis = $this->validateRequired($this->sanitiseString($post['synopsis'] ?? ''), 'Synopsis');
$durationInfo = $this->sanitiseString($post['duration_info'] ?? '');
// Jury members
$juryMembers = [];
if (!empty(trim($post['jury_president'] ?? ''))) {
$juryMembers[] = ['name' => trim($post['jury_president']), 'role' => 'president', 'is_external' => 0];
}
if (!empty(trim($post['jury_promoteur'] ?? ''))) {
$juryMembers[] = [
'name' => trim($post['jury_promoteur']),
'role' => 'promoteur',
'is_external' => isset($post['jury_promoteur_ext']) ? 1 : 0,
];
}
foreach ($post['jury_lecteurs'] ?? [] as $i => $name) {
$name = trim($name);
if ($name !== '') {
$juryMembers[] = [
'name' => $name,
'role' => 'lecteur',
'is_external' => isset($post['jury_lecteurs_ext'][$i]) ? 1 : 0,
];
}
}
// Keywords (max 10)
$tagRaw = $this->sanitiseString($post['tag'] ?? '');
$keywords = $tagRaw !== '' ? array_map('trim', explode(',', $tagRaw)) : [];
if (count($keywords) > 10) {
throw new Exception('Maximum 10 mots-clés autorisés.');
}
// Languages (at least one required)
$languageIds = isset($post['languages']) && is_array($post['languages'])
? array_map('intval', $post['languages'])
: [];
if (empty($languageIds)) {
throw new Exception('Veuillez sélectionner au moins une langue.');
}
// Formats (optional)
$formatIds = isset($post['formats']) && is_array($post['formats'])
? array_map('intval', $post['formats'])
: [];
$licenseId = filter_var($post['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null;
// Access type — must be one of the enabled types; default 2 (Interne)
$accessTypeId = filter_var($post['access_type_id'] ?? '', FILTER_VALIDATE_INT);
if ($accessTypeId === false || $accessTypeId <= 0) {
$accessTypeId = 2; // Interne
}
// External link (optional)
$lien = '';
if (!empty($post['lien'])) {
$lien = filter_var($post['lien'], FILTER_VALIDATE_URL);
if ($lien === false) {
throw new Exception('Lien URL invalide.');
}
}
return compact(
'auteurName', 'mail', 'showContact', 'annee', 'orientationId', 'apProgramId',
'finalityId', 'titre', 'subtitle', 'synopsis', 'durationInfo',
'juryMembers', 'keywords', 'languageIds', 'formatIds',
'licenseId', 'lien', 'accessTypeId'
);
}
// ── Private: file uploads ─────────────────────────────────────────────────
/**
* Process an optional cover image upload and record it in thesis_files.
*
* @param int $thesisId
* @param array|null $upload Single-file $_FILES entry (may be null or have UPLOAD_ERR_NO_FILE).
*/
private function handleCoverUpload(int $thesisId, ?array $upload): void
{
if (!$upload || !isset($upload['error']) || $upload['error'] !== UPLOAD_ERR_OK) {
return;
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($upload['tmp_name']);
$ext = strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION));
if (!in_array($mimeType, ['image/jpeg', 'image/png'], true)
|| !in_array($ext, ['jpg', 'jpeg', 'png'], true)) {
error_log("ThesisCreateController: invalid cover MIME $mimeType, skipping");
return;
}
$coverDir = STORAGE_ROOT . '/covers/';
if (!is_dir($coverDir)) {
mkdir($coverDir, 0755, true);
}
$safeName = bin2hex(random_bytes(16)) . '.' . $ext;
$targetPath = $coverDir . $safeName;
if (!move_uploaded_file($upload['tmp_name'], $targetPath)) {
error_log("ThesisCreateController: failed to move cover to $targetPath");
return;
}
chmod($targetPath, 0644);
$relPath = 'covers/' . $safeName;
$this->db->insertThesisFile($thesisId, 'cover', $relPath, basename($upload['name']), $upload['size'], $mimeType);
error_log("ThesisCreateController: cover uploaded → $safeName");
}
/**
* Process multiple thesis-file uploads (PDFs, images, videos, ZIPs, VTTs).
*
* @param int $thesisId
* @param int $year Used for the storage sub-directory path.
* @param string $identifier Thesis identifier slug (e.g. "2024-003").
* @param array|null $uploads Multi-file $_FILES entry (may be null).
*/
private function handleThesisFiles(int $thesisId, int $year, string $identifier, ?array $uploads): void
{
if (!$uploads || !is_array($uploads['name'] ?? null)) {
return;
}
$uploadDir = STORAGE_ROOT . "/theses/{$year}/{$identifier}/";
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
$count = count($uploads['name']);
for ($i = 0; $i < $count; $i++) {
if (($uploads['error'][$i] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
continue;
}
if (($uploads['error'][$i] ?? -1) !== UPLOAD_ERR_OK) {
error_log("ThesisCreateController: upload error code {$uploads['error'][$i]} for {$uploads['name'][$i]}");
continue;
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($uploads['tmp_name'][$i]);
$ext = strtolower(pathinfo($uploads['name'][$i], PATHINFO_EXTENSION));
// finfo may return 'text/plain' for WebVTT on some systems.
if ($mimeType === 'text/plain' && $ext === 'vtt') {
$mimeType = 'text/vtt';
}
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)
|| !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
error_log("ThesisCreateController: invalid file type {$uploads['name'][$i]} ($mimeType), skipping");
continue;
}
if ($uploads['size'][$i] > self::MAX_FILE_SIZE) {
error_log("ThesisCreateController: file too large {$uploads['name'][$i]}, skipping");
continue;
}
$safeName = bin2hex(random_bytes(16)) . '.' . $ext;
$targetPath = $uploadDir . $safeName;
if (!move_uploaded_file($uploads['tmp_name'][$i], $targetPath)) {
error_log("ThesisCreateController: failed to move file {$uploads['name'][$i]}");
continue;
}
chmod($targetPath, 0644);
$fileType = 'other';
if ($ext === 'vtt') {
$fileType = 'caption';
} elseif (stripos($uploads['name'][$i], 'annex') !== false) {
$fileType = 'annex';
} elseif ($ext === 'pdf') {
$fileType = 'main';
}
$relPath = "theses/{$year}/{$identifier}/" . $safeName;
$this->db->insertThesisFile(
$thesisId,
$fileType,
$relPath,
basename($uploads['name'][$i]),
$uploads['size'][$i],
$mimeType
);
error_log("ThesisCreateController: file uploaded → $safeName ($fileType)");
}
}
// ── Private: input helpers ────────────────────────────────────────────────
/**
* Trim and strip HTML tags from a string value.
* htmlspecialchars is applied at render time, not here.
*/
private function sanitiseString(string $input): string
{
return strip_tags(trim($input));
}
/**
* Assert that a string value is non-empty.
*
* @throws Exception if $value is empty.
*/
private function validateRequired(string $value, string $fieldName): string
{
if ($value === '') {
throw new Exception("Le champ '$fieldName' est requis.");
}
return $value;
}
}

View File

@@ -0,0 +1,293 @@
<?php
/**
* ThesisEditController
*
* Centralises all data-fetching and mutation logic for the admin thesis-edit
* workflow (admin/edit.php + admin/actions/edit.php).
*
* Responsibilities:
* - Loading thesis data and lookup tables for the edit form view
* - Validating and persisting POST submissions (thesis metadata, authors,
* jury, languages, formats, tags, banner)
* - WCAG 3.3.1: mapping validation exceptions to autofocus field hints
*
* The class has NO output side-effects; all redirects, flash writes, and
* template rendering stay in the thin dispatcher files so the view layer
* remains easy to inspect and modify.
*/
class ThesisEditController
{
private Database $db;
public function __construct(Database $db)
{
$this->db = $db;
}
// ── Factory ───────────────────────────────────────────────────────────────
/**
* Convenience factory — instantiates Database and returns a ready
* controller. Accepts an optional existing Database instance so callers
* that already hold one (e.g. during testing) can avoid a second
* connection.
*/
public static function create(?Database $db = null): self
{
require_once APP_ROOT . '/src/Database.php';
return new self($db ?? Database::getInstance());
}
// ── Read / view data ─────────────────────────────────────────────────────
/**
* Load all data required to render the edit form.
*
* Returns a flat array of view variables:
* - 'thesis' thesis row (from getThesis)
* - 'currentLanguages' int[]
* - 'currentFormats' int[]
* - 'jury' jury rows
* - 'orientations' lookup rows
* - 'apPrograms' lookup rows
* - 'finalityTypes' lookup rows
* - 'languages' lookup rows
* - 'formatTypes' lookup rows
* - 'licenseTypes' lookup rows
* - 'accessTypes' lookup rows
* - 'currentLicenseId' int|null
* - 'currentAccessTypeId' int|null
* - 'currentContextNote' string
* - 'pageTitle' string
*
* @throws Exception if the thesis is not found or a DB error occurs.
*/
public function load(int $thesisId): array
{
if ($thesisId <= 0) {
throw new InvalidArgumentException("ID invalide");
}
$thesis = $this->db->getThesis($thesisId);
if (!$thesis) {
throw new RuntimeException("TFE non trouvé");
}
$currentLanguages = $this->db->getThesisLanguageIds($thesisId);
$currentFormats = $this->db->getThesisFormatIds($thesisId);
$jury = $this->db->getThesisJury($thesisId);
$orientations = $this->db->getAllOrientations();
$apPrograms = $this->db->getAllAPPrograms();
$finalityTypes = $this->db->getAllFinalityTypes();
$languages = $this->db->getAllLanguages();
$formatTypes = $this->db->getAllFormatTypes();
$licenseTypes = $this->db->getAllLicenseTypes();
$accessTypes = $this->db->getAccessTypes();
$rawRow = $this->db->getThesisRawFields($thesisId);
$currentLicenseId = $rawRow['license_id'] ?? null;
$currentAccessTypeId = $rawRow['access_type_id'] ?? null;
$currentContextNote = $rawRow['context_note'] ?? '';
// Author contact info (from view)
$currentAuthorEmail = $thesis['author_email'] ?? '';
$currentAuthorShowContact = (bool)($thesis['author_show_contact'] ?? false);
return [
'thesis' => $thesis,
'currentLanguages' => $currentLanguages,
'currentFormats' => $currentFormats,
'jury' => $jury,
'orientations' => $orientations,
'apPrograms' => $apPrograms,
'finalityTypes' => $finalityTypes,
'languages' => $languages,
'formatTypes' => $formatTypes,
'licenseTypes' => $licenseTypes,
'accessTypes' => $accessTypes,
'currentLicenseId' => $currentLicenseId,
'currentAccessTypeId' => $currentAccessTypeId,
'currentContextNote' => $currentContextNote,
'currentAuthorEmail' => $currentAuthorEmail,
'currentAuthorShowContact' => $currentAuthorShowContact,
'pageTitle' => 'Éditer TFE - ' . htmlspecialchars($thesis['title']),
];
}
// ── Write / action ────────────────────────────────────────────────────────
/**
* Validate and persist a thesis-edit POST submission.
*
* Runs the full update inside a transaction:
* 1. Thesis metadata (title, subtitle, year, orientation, ap, finality,
* synopsis, context_note, file_size_info, baiu_link, license_id,
* access_type_id, is_published)
* 2. Authors (setThesisAuthors)
* 3. Jury (setThesisJury)
* 4. Languages (setThesisLanguages)
* 5. Formats (setThesisFormats)
* 6. Tags (setThesisTags)
* Then handles banner upload/removal outside the transaction.
*
* @param int $thesisId Validated thesis ID (> 0).
* @param array $post Sanitised $_POST array.
* @param array $files $_FILES array (expects 'banner' key).
*
* @throws Exception on validation or DB error (caller must rollback if
* the transaction is still open, but this method rolls
* back internally before re-throwing).
*/
public function save(int $thesisId, array $post, array $files): void
{
if ($thesisId <= 0) {
throw new InvalidArgumentException("ID de TFE invalide.");
}
$this->db->beginTransaction();
try {
// ── 1. Thesis metadata ────────────────────────────────────────────
$this->db->updateThesis($thesisId, [
'title' => trim($post['titre'] ?? ''),
'subtitle' => trim($post['subtitle'] ?? ''),
'year' => intval($post['année'] ?? 0),
'orientation_id' => intval($post['orientation'] ?? 0),
'ap_program_id' => intval($post['ap'] ?? 0),
'finality_id' => intval($post['finality'] ?? 0),
'synopsis' => trim($post['synopsis'] ?? ''),
'context_note' => trim($post['context_note'] ?? ''),
'file_size_info' => trim($post['duration_info'] ?? ''),
'baiu_link' => trim($post['lien'] ?? ''),
'license_id' => filter_var($post['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null,
'access_type_id' => filter_var($post['access_type_id'] ?? '', FILTER_VALIDATE_INT) ?: null,
'is_published' => isset($post['is_published']),
]);
// ── 2. Authors ────────────────────────────────────────────────────
$authorsRaw = trim($post['auteurice'] ?? '');
$showContact = !empty($post['contact_public']);
$authorEntries = [];
if ($authorsRaw !== '') {
foreach (array_map('trim', explode(',', $authorsRaw)) as $i => $name) {
if ($name !== '') {
$authorEntries[] = [
'name' => $name,
'email' => $i === 0 ? ($post['mail'] ?? null) : null,
'show_contact' => $i === 0 ? $showContact : false,
];
}
}
}
$this->db->setThesisAuthors($thesisId, $authorEntries);
// ── 3. Jury ───────────────────────────────────────────────────────
$juryMembers = $this->collectJuryMembers($post);
$this->db->setThesisJury($thesisId, $juryMembers);
// ── 4. Languages ──────────────────────────────────────────────────
$this->db->setThesisLanguages(
$thesisId,
isset($post['languages']) && is_array($post['languages'])
? $post['languages']
: []
);
// ── 5. Formats ────────────────────────────────────────────────────
$this->db->setThesisFormats(
$thesisId,
isset($post['formats']) && is_array($post['formats'])
? $post['formats']
: []
);
// ── 6. Tags ───────────────────────────────────────────────────────
$keywordsRaw = trim($post['tag'] ?? '');
$keywords = $keywordsRaw !== ''
? array_map('trim', explode(',', $keywordsRaw))
: [];
$this->db->setThesisTags($thesisId, $keywords);
$this->db->commit();
} catch (Exception $e) {
$this->db->rollback();
throw $e;
}
// ── Banner (outside transaction — filesystem op) ──────────────────────
if (isset($post['remove_banner'])) {
$currentBannerPath = $this->db->getThesisBannerPath($thesisId);
if ($currentBannerPath && defined('STORAGE_ROOT')) {
$absPath = STORAGE_ROOT . '/' . $currentBannerPath;
if (file_exists($absPath)) {
unlink($absPath);
}
}
$this->db->setBannerPath($thesisId, null);
} else {
$this->db->handleBannerUpload($thesisId, $files['banner'] ?? null);
}
}
// ── WCAG 3.3.1 helper ─────────────────────────────────────────────────────
/**
* Map a validation exception message to the name of the field that should
* receive autofocus when the form is re-rendered.
*
* Returns null when no field mapping is found.
*/
public static function autofocusFieldForError(string $message): ?string
{
if (str_contains($message, 'titre') || str_contains($message, 'Titre')) return 'titre';
if (str_contains($message, 'année') || str_contains($message, 'Année')) return 'année';
if (str_contains($message, 'synopsis') || str_contains($message, 'Synopsis')) return 'synopsis';
if (str_contains($message, 'auteur') || str_contains($message, 'Auteur')) return 'auteurice';
return null;
}
// ── Private helpers ───────────────────────────────────────────────────────
/**
* Build the jury-members array from POST data.
*
* @param array $post Raw $_POST.
* @return array<int, array{name: string, role: string, is_external: int}>
*/
private function collectJuryMembers(array $post): array
{
$members = [];
if (!empty(trim($post['jury_president'] ?? ''))) {
$members[] = [
'name' => trim($post['jury_president']),
'role' => 'president',
'is_external' => 0,
];
}
if (!empty(trim($post['jury_promoteur'] ?? ''))) {
$members[] = [
'name' => trim($post['jury_promoteur']),
'role' => 'promoteur',
'is_external' => isset($post['jury_promoteur_ext']) ? 1 : 0,
];
}
foreach ($post['jury_lecteurs'] ?? [] as $i => $name) {
$name = trim($name);
if ($name !== '') {
$members[] = [
'name' => $name,
'role' => 'lecteur',
'is_external' => isset($post['jury_lecteurs_ext'][$i]) ? 1 : 0,
];
}
}
return $members;
}
}

1848
app/src/Database.php Normal file

File diff suppressed because it is too large Load Diff

151
app/src/Dispatcher.php Normal file
View File

@@ -0,0 +1,151 @@
<?php
/**
* Front-controller Dispatcher
*
* Routes all public-page requests through a single entry point.
* Admin panel (/admin/*) and static assets bypass the dispatcher.
*
* Routes:
* / → HomeController → home view
* /search.php → SearchController → search view
* /repertoire → SearchController → repertoire view
* /tfe/<id> → TfeController → tfe view
* /apropos → AboutController → about view
* /licence → LicenceController → licence view
* /media.php → MediaController (direct output)
* /live-reload → LiveReloadController (direct output)
* /partage/<slug> → share-link flow
* /maintenance.php → static maintenance page
*/
class Dispatcher {
private const ROUTES = [
'' => ['controller' => 'HomeController', 'action' => 'handle', 'view' => 'public/home'],
'/' => ['controller' => 'HomeController', 'action' => 'handle', 'view' => 'public/home'],
'/index.php' => ['controller' => 'HomeController', 'action' => 'handle', 'view' => 'public/home'],
'/search.php' => ['controller' => 'SearchController', 'action' => 'handle', 'view' => 'public/search'],
'/repertoire' => ['controller' => 'SearchController', 'action' => 'handleRepertoire', 'view' => 'public/repertoire'],
'/repertoire.php' => ['controller' => 'SearchController', 'action' => 'handleRepertoire', 'view' => 'public/repertoire'],
'/tfe.php' => ['controller' => 'TfeController', 'action' => 'handle', 'view' => 'public/tfe'],
'/apropos' => ['controller' => 'AboutController', 'action' => 'handle', 'view' => 'public/about'],
'/apropos.php' => ['controller' => 'AboutController', 'action' => 'handle', 'view' => 'public/about'],
'/licence' => ['controller' => 'LicenceController', 'action' => 'handle', 'view' => 'public/licence'],
'/licence.php' => ['controller' => 'LicenceController', 'action' => 'handle', 'view' => 'public/licence'],
];
private string $path;
private array $queryParams;
public function __construct() {
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$this->path = $uri;
$this->queryParams = $_GET;
}
/**
* Resolve the URI to a route, instantiate the controller,
* execute the action, and render the view.
*/
public function dispatch(): void {
// 1. Direct-response endpoints (render their own output)
$direct = $this->matchDirect();
if ($direct) {
$direct();
return;
}
// 2. Routed pages (controller + view)
$route = $this->matchRoute();
if (!$route) {
http_response_code(404);
echo '<h1>404 — Page non trouvée</h1>';
return;
}
// 3. Load controller
$ctrlClass = $route['controller'];
require_once APP_ROOT . '/src/Controllers/' . $ctrlClass . '.php';
$controller = $ctrlClass::create();
$vars = $controller->{$route['action']}();
// 4. Render view
$this->render($route['view'], $vars);
}
/**
* Match endpoints that render their own response (no view layer).
*/
private function matchDirect(): ?callable {
$path = $this->path;
// /live-reload
if ($path === '/live-reload' || $path === '/live-reload.php') {
return function() {
require_once APP_ROOT . '/src/Controllers/LiveReloadController.php';
$controller = new LiveReloadController(APP_ROOT);
$result = $controller->handle();
header('Content-Type: application/json');
echo json_encode($result['body']);
};
}
// /media.php
if ($path === '/media' || $path === '/media.php') {
return function() {
require_once APP_ROOT . '/src/Controllers/MediaController.php';
$controller = new MediaController();
$controller->handle();
};
}
// /maintenance.php
if ($path === '/maintenance' || $path === '/maintenance.php') {
return function() {
require APP_ROOT . '/public/maintenance.php';
};
}
// /partage/*
if (preg_match('#^/partage(/.*)?$#', $path)) {
return function() {
require APP_ROOT . '/public/partage/index.php';
};
}
return null;
}
/**
* Match the current path against the static route table.
* Supports exact match and prefix-based (for /tfe?id=).
*/
private function matchRoute(): ?array {
$path = $this->path;
// Exact match first
if (isset(self::ROUTES[$path])) {
return self::ROUTES[$path];
}
// /tfe?id= pattern (TFeController handles the id param internally)
if (preg_match('#^/tfe$#', $path) && isset($_GET['id'])) {
return self::ROUTES['/tfe.php'];
}
return null;
}
/**
* Render a view template, passing controller data through extract().
*/
private function render(string $view, array $vars): void {
$viewPath = APP_ROOT . '/templates/' . $view . '.php';
if (!file_exists($viewPath)) {
http_response_code(500);
echo "View not found: {$viewPath}";
return;
}
extract($vars);
include $viewPath;
}
}

1995
app/src/Parsedown.php Normal file

File diff suppressed because it is too large Load Diff

193
app/src/RateLimit.php Normal file
View File

@@ -0,0 +1,193 @@
<?php
/**
* Simple file-based rate limiter
* Prevents abuse by limiting requests per IP address
*/
class RateLimit {
private $cacheDir;
private $maxRequests;
private $timeWindow;
/**
* Constructor
* @param int $maxRequests Maximum requests allowed in time window
* @param int $timeWindow Time window in seconds
* @param string $cacheDir Directory to store rate limit data
*/
public function __construct($maxRequests = 30, $timeWindow = 60, $cacheDir = null) {
$this->maxRequests = $maxRequests;
$this->timeWindow = $timeWindow;
$this->cacheDir = $cacheDir ?? dirname(__DIR__) . '/storage/cache/rate_limit';
// Create cache directory if it doesn't exist
if (!is_dir($this->cacheDir)) {
@mkdir($this->cacheDir, 0755, true);
}
}
/**
* Get client identifier (IP address)
* @return string Client identifier
*/
private function getClientIdentifier(): string {
// Use REMOTE_ADDR only — HTTP_X_FORWARDED_FOR and HTTP_CLIENT_IP
// are fully attacker-controlled request headers and must never be
// trusted for rate-limiting purposes (an attacker can rotate them
// freely to bypass the limiter). Nginx-level rate limiting also
// uses $binary_remote_addr for the same reason. If this app is
// ever placed behind a trusted reverse-proxy, IP extraction should
// be handled at the nginx level, not here.
return $_SERVER['REMOTE_ADDR'] ?? 'unknown';
}
/**
* Get cache file path for a client
* @param string $identifier Client identifier
* @return string File path
*/
private function getCacheFile($identifier) {
return $this->cacheDir . '/' . md5($identifier) . '.json';
}
/**
* Check and record a hit for an arbitrary string key.
* Useful when the caller wants a compound key (e.g. slug + IP).
*
* @return bool True if allowed, false if rate limit exceeded
*/
public function checkKey(string $key): bool {
$file = $this->getCacheFile($key);
$data = [];
if (file_exists($file)) {
$data = json_decode(file_get_contents($file), true) ?? [];
}
$now = time();
$data = array_values(array_filter($data, fn($ts) => ($now - $ts) < $this->timeWindow));
if (count($data) >= $this->maxRequests) {
return false;
}
$data[] = $now;
if (is_dir($this->cacheDir) && is_writable($this->cacheDir)) {
file_put_contents($file, json_encode($data));
}
return true;
}
/**
* Check if client has exceeded rate limit
* @return bool True if allowed, false if rate limit exceeded
*/
public function check() {
$identifier = $this->getClientIdentifier();
$file = $this->getCacheFile($identifier);
// Load existing request timestamps
$data = [];
if (file_exists($file)) {
$content = file_get_contents($file);
$data = json_decode($content, true) ?? [];
}
// Clean old entries outside time window
$now = time();
$data = array_filter($data, function($timestamp) use ($now) {
return ($now - $timestamp) < $this->timeWindow;
});
// Check if limit exceeded
if (count($data) >= $this->maxRequests) {
return false;
}
// Add new request timestamp
$data[] = $now;
// Save updated data (silently skip if directory is not writable)
if (is_dir($this->cacheDir) && is_writable($this->cacheDir)) {
file_put_contents($file, json_encode($data));
}
return true;
}
/**
* Get remaining requests for current client
* @return int Number of requests remaining
*/
public function getRemaining() {
$identifier = $this->getClientIdentifier();
$file = $this->getCacheFile($identifier);
if (!file_exists($file)) {
return $this->maxRequests;
}
$content = file_get_contents($file);
$data = json_decode($content, true) ?? [];
// Clean old entries
$now = time();
$data = array_filter($data, function($timestamp) use ($now) {
return ($now - $timestamp) < $this->timeWindow;
});
return max(0, $this->maxRequests - count($data));
}
/**
* Get time until rate limit resets
* @return int Seconds until reset
*/
public function getResetTime() {
$identifier = $this->getClientIdentifier();
$file = $this->getCacheFile($identifier);
if (!file_exists($file)) {
return 0;
}
$content = file_get_contents($file);
$data = json_decode($content, true) ?? [];
if (empty($data)) {
return 0;
}
// Find oldest timestamp
$oldest = min($data);
$resetTime = $oldest + $this->timeWindow - time();
return max(0, $resetTime);
}
/**
* Clean up old cache files (run periodically)
* Removes files that haven't been modified in 24 hours
*/
public function cleanup() {
$files = glob($this->cacheDir . '/*.json');
$cutoff = time() - 86400; // 24 hours
foreach ($files as $file) {
if (filemtime($file) < $cutoff) {
unlink($file);
}
}
}
/**
* Send rate limit headers
* Provides information about rate limits to clients
*/
public function sendHeaders() {
header('X-RateLimit-Limit: ' . $this->maxRequests);
header('X-RateLimit-Remaining: ' . $this->getRemaining());
header('X-RateLimit-Reset: ' . (time() + $this->getResetTime()));
}
}

201
app/src/ShareLink.php Normal file
View File

@@ -0,0 +1,201 @@
<?php
/**
* ShareLink — model for student-access share links.
*
* Share links enable students to submit TFEs via unique URLs without
* requiring admin authentication. Each link has a unique slug, optional
* password, activity flag, optional expiration, and usage count.
*/
class ShareLink
{
private Database $db;
public function __construct(Database $db)
{
$this->db = $db;
}
public static function make(): self
{
require_once APP_ROOT . '/src/Database.php';
return new self(new Database());
}
// ── Slug generation ───────────────────────────────────────────────────────
/**
* Generate a unique slug in the format YYYYMMDD-<random>.
* The random portion uses 8 base32 chars (~40 bits of entropy).
*/
public static function generateSlug(): string
{
$date = date('Ymd');
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$random = '';
for ($i = 0; $i < 8; $i++) {
$random .= $chars[random_int(0, strlen($chars) - 1)];
}
return $date . '-' . $random;
}
// ── CRUD ──────────────────────────────────────────────────────────────────
/**
* Create a new share link.
*
* @param int $createdBy Admin user ID
* @param string|null $password Plain-text password (will be hashed), null = no password
* @param string|null $expiresAt ISO-8601 expiration date, null = never expires
* @return array The created link row
*/
public function create(int $createdBy, ?string $password = null, ?string $expiresAt = null): array
{
$slug = self::generateSlug();
$passwordHash = $password !== null ? password_hash($password, PASSWORD_BCRYPT) : null;
$stmt = $this->db->getConnection()->prepare(
"INSERT INTO share_links (slug, password_hash, is_active, created_by, expires_at)
VALUES (?, ?, 1, ?, ?)"
);
$stmt->execute([$slug, $passwordHash, $createdBy, $expiresAt]);
return $this->findBySlug($slug);
}
/**
* Find a share link by its slug.
*
* @return array|null
*/
public function findBySlug(string $slug): ?array
{
$stmt = $this->db->getConnection()->prepare(
"SELECT * FROM share_links WHERE slug = ?"
);
$stmt->execute([$slug]);
$row = $stmt->fetch();
return $row ?: null;
}
/**
* Find a share link by its ID.
*
* @return array|null
*/
public function findById(int $id): ?array
{
$stmt = $this->db->getConnection()->prepare(
"SELECT * FROM share_links WHERE id = ?"
);
$stmt->execute([$id]);
$row = $stmt->fetch();
return $row ?: null;
}
/**
* List all share links, ordered by creation date descending.
*
* @return array
*/
public function listAll(): array
{
$stmt = $this->db->getConnection()->query(
"SELECT * FROM share_links ORDER BY created_at DESC"
);
return $stmt->fetchAll();
}
/**
* Toggle the active status of a share link.
*/
public function toggleActive(int $id): void
{
$this->db->getConnection()->prepare(
"UPDATE share_links SET is_active = NOT is_active WHERE id = ?"
)->execute([$id]);
}
/**
* Set or clear the password for a share link.
*
* @param string|null $password Plain-text password, or null to clear
*/
public function setPassword(int $id, ?string $password): void
{
$hash = $password !== null ? password_hash($password, PASSWORD_BCRYPT) : null;
$this->db->getConnection()->prepare(
"UPDATE share_links SET password_hash = ? WHERE id = ?"
)->execute([$hash, $id]);
}
/**
* Delete a share link.
*/
public function delete(int $id): void
{
$this->db->getConnection()->prepare(
"DELETE FROM share_links WHERE id = ?"
)->execute([$id]);
}
/**
* Increment the usage count for a share link.
*/
public function incrementUsage(int $id): void
{
$this->db->getConnection()->prepare(
"UPDATE share_links SET usage_count = usage_count + 1 WHERE id = ?"
)->execute([$id]);
}
// ── Validation ────────────────────────────────────────────────────────────
/**
* Validate whether a share link is usable.
*
* Returns an array:
* ['valid' => true] if the link is active and not expired
* ['valid' => false, 'reason' => 'disabled'] if deactivated
* ['valid' => false, 'reason' => 'expired'] if past expiration
* ['valid' => false, 'reason' => 'not_found'] if slug doesn't exist
* ['valid' => false, 'reason' => 'needs_password', 'link' => array] if password required
*/
public function validateLink(?string $slug): array
{
if ($slug === null || $slug === '') {
return ['valid' => false, 'reason' => 'not_found'];
}
$link = $this->findBySlug($slug);
if ($link === null) {
return ['valid' => false, 'reason' => 'not_found'];
}
if (!$link['is_active']) {
return ['valid' => false, 'reason' => 'disabled', 'link' => $link];
}
if ($link['expires_at'] !== null && strtotime($link['expires_at']) < time()) {
return ['valid' => false, 'reason' => 'expired', 'link' => $link];
}
if ($link['password_hash'] !== null) {
return ['valid' => false, 'reason' => 'needs_password', 'link' => $link];
}
return ['valid' => true, 'link' => $link];
}
/**
* Verify the password against a share link.
*
* @return bool
*/
public function verifyPassword(array $link, string $password): bool
{
if ($link['password_hash'] === null) {
return true; // No password set
}
return password_verify($password, $link['password_hash']);
}
}

181
app/src/SmtpRelay.php Normal file
View File

@@ -0,0 +1,181 @@
<?php
/**
* SMTP Relay — credentials stored in the DB, sending via PHP's built-in mail
* wrappers (SMTP transport layer is wired later).
*
* Responsibilities:
* 1. CRUD on the singleton smtp_settings row.
* 2. Build MIME messages.
* 3. Send via `mail()` now; swap transport later (e.g. PHPMailer / Symfony Mailer).
*/
class SmtpRelay {
// -----------------------------------------------------------------------
// DB operations
// -----------------------------------------------------------------------
/**
* Fetch current SMTP settings from the DB.
*
* @return array{host:string,port:int,encryption:string,username:string,password:string,from_email:string,from_name:string}
*/
public static function getSettings(Database $db): array {
$stmt = $db->getPDO()->query(
"SELECT host, port, encryption, username, password, from_email, from_name
FROM v_smtp_active LIMIT 1"
);
$row = $stmt->fetch();
return $row ?: [
'host' => '',
'port' => 587,
'encryption' => 'tls',
'username' => '',
'password' => '',
'from_email' => '',
'from_name' => 'Post-ERG',
];
}
/**
* Upsert SMTP settings.
*
* @param array $data Associative array with keys: host, port, encryption,
* username, password, from_email, from_name.
* Keys not present are left unchanged.
*/
public static function updateSettings(Database $db, array $data): void {
// Read existing so we can merge partial updates
$current = self::getSettings($db);
$merged = array_merge($current, $data);
// Sanitize
$port = max(1, min(65535, (int)$merged['port']));
$encryption = in_array($merged['encryption'], ['tls', 'ssl', 'none'], true)
? $merged['encryption'] : 'tls';
$stmt = $db->getPDO()->prepare(
"UPDATE smtp_settings
SET host = :host,
port = :port,
encryption = :encryption,
username = :username,
password = :password,
from_email = :from_email,
from_name = :from_name,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1"
);
$stmt->execute([
':host' => trim($merged['host']),
':port' => $port,
':encryption' => $encryption,
':username' => trim($merged['username']),
':password' => $merged['password'], // keep as-is
':from_email' => trim($merged['from_email']),
':from_name' => trim($merged['from_name']),
]);
}
/**
* Check whether the SMTP relay is fully configured.
*/
public static function isConfigured(Database $db): bool {
$s = self::getSettings($db);
return $s['host'] !== '' && $s['username'] !== '' && $s['from_email'] !== '';
}
// -----------------------------------------------------------------------
// Send helpers (transport wired later — stub implementation now)
// -----------------------------------------------------------------------
/**
* Send an e-mail using the stored SMTP credentials.
*
* Currently uses PHP's `mail()` as a passthrough so the rest of the
* application can call `SmtpRelay::send(…)` everywhere.
* The actual SMTP transport layer will be wired in a later iteration
* (e.g. replace this body with PHPMailer / Symfony Mailer).
*
* @param string $to Recipient e-mail address
* @param string $subject Subject line
* @param string $body HTML body
* @param string $plain Plain-text alternative (optional)
* @return bool True on send request acceptance; false on failure
*/
public static function send(
Database $db,
string $to,
string $subject,
string $body,
string $plain = ''
): bool {
$settings = self::getSettings($db);
if ($settings['from_email'] === '') {
error_log('[SmtpRelay] send() aborted — no from_email configured');
return false;
}
// Build MIME multipart headers
$boundary = 'posterg_' . md5((string) random_int(0, PHP_INT_MAX) . microtime(true));
$headers = "From: {$settings['from_name']} <{$settings['from_email']}>\r\n";
$headers .= "Reply-To: {$settings['from_email']}\r\n";
$headers .= "MIME-Version: 1.0\r\n";
if ($plain !== '') {
$headers .= "Content-Type: multipart/alternative; boundary=\"{$boundary}\"\r\n";
$message = "--{$boundary}\r\n";
$message .= "Content-Type: text/plain; charset=UTF-8\r\n\r\n";
$message .= self::htmlToPlain($body) . "\r\n\r\n";
$message .= "--{$boundary}\r\n";
$message .= "Content-Type: text/html; charset=UTF-8\r\n\r\n";
$message .= $body . "\r\n\r\n";
$message .= "--{$boundary}--";
} else {
$headers .= "Content-Type: text/html; charset=UTF-8\r\n";
$message = $body;
}
// TODO: replace with real SMTP transport (PHPMailer / Symfony Mailer)
// The stored credentials ($settings) will be passed to the mailer then.
$ok = mail($to, $subject, $message, $headers);
if (!$ok) {
error_log("[SmtpRelay] mail() returned false for {$to}");
}
return $ok;
}
/**
* Queue (persist) an e-mail for deferred sending.
*
* Stub — will create a `mail_queue` table in a future migration.
*/
public static function queue(
Database $db,
string $to,
string $subject,
string $body,
string $plain = ''
): void {
// TODO: INSERT INTO mail_queue …
// Placeholder so callers exist now and wire up later.
}
// -----------------------------------------------------------------------
// Internal
// -----------------------------------------------------------------------
/**
* Strip HTML tags to produce a rough plain-text fallback.
*/
private static function htmlToPlain(string $html): string {
$text = strip_tags($html);
// Collapse multiple whitespace lines
$text = preg_replace('/\n{3,}/', "\n\n", $text);
return trim($text);
}
}

111
app/src/SystemCache.php Normal file
View File

@@ -0,0 +1,111 @@
<?php
/**
* SystemCache — thin TTL cache for admin system page checks.
*
* Stores JSON-encoded data blobs in the `system_cache` SQLite table.
* The table has a single schema:
* key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL
*
* Usage:
* $cache = new SystemCache($pdo);
*
* // Read (returns array or null if stale/missing)
* $data = $cache->get('system_status', 120);
*
* // Write
* $cache->set('system_status', $myArray);
*
* // Check freshness without reading value
* if ($cache->isStale('disk_info', 300)) { ... }
*
* // Force-invalidate a key (e.g. on ?refresh=1)
* $cache->invalidate('system_status');
*/
class SystemCache
{
private PDO $pdo;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
/**
* Return cached data for $key if it is no older than $maxAgeSec seconds.
* Returns null when the entry is missing or stale.
*/
public function get(string $key, int $maxAgeSec = 60): ?array
{
$stmt = $this->pdo->prepare(
'SELECT value, updated_at FROM system_cache WHERE key = ?'
);
$stmt->execute([$key]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return null;
}
if ((time() - (int)$row['updated_at']) > $maxAgeSec) {
return null; // stale
}
$decoded = json_decode((string)$row['value'], true);
return is_array($decoded) ? $decoded : null;
}
/**
* Upsert $data (JSON-encoded) for $key with current timestamp.
*/
public function set(string $key, array $data): void
{
$stmt = $this->pdo->prepare(
'INSERT INTO system_cache (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at'
);
$stmt->execute([$key, json_encode($data), time()]);
}
/**
* Return true when the entry is missing or older than $maxAgeSec.
*/
public function isStale(string $key, int $maxAgeSec = 60): bool
{
$stmt = $this->pdo->prepare(
'SELECT updated_at FROM system_cache WHERE key = ?'
);
$stmt->execute([$key]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return true;
}
return (time() - (int)$row['updated_at']) > $maxAgeSec;
}
/**
* Return the age of the cached entry in seconds, or null if missing.
*/
public function ageSeconds(string $key): ?int
{
$stmt = $this->pdo->prepare(
'SELECT updated_at FROM system_cache WHERE key = ?'
);
$stmt->execute([$key]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? (time() - (int)$row['updated_at']) : null;
}
/**
* Delete a cached entry, forcing the next get() to re-compute.
*/
public function invalidate(string $key): void
{
$stmt = $this->pdo->prepare('DELETE FROM system_cache WHERE key = ?');
$stmt->execute([$key]);
}
}

0
app/storage/.gitkeep Normal file
View File

View File

@@ -0,0 +1,74 @@
,,,,,,,,,,,,,,,,,,,,
Identifiant,Titre,Sous-titre,Auteur·ice(s),Contact,Promoteur·ice(s),Format,Année,AP,Orientation,Finalité,Mots-clés,Synopsis,Contexte,Remarques,Langue,Autorisation,License,taille,Points sur 20,lien BAIU
,,,,,,,,,,,séparer par une virgule — max. 10,,,,,,,,,
Column1,Column2,Column3,Column4,Column5,Column6,Column7,Column8,Column9,Column10,Column11,Column12,Column13,Column14,Column15,Column16,Column17,Column18,Column19,Column20,
2025-002,"Plus haut, la poussière ira plus loin",,Lucie Jacquet,luciejacquetlucie@gmail.com,"Silvia Mesturini, Sasha Newell",objet éditorial,2025,,SC,Approfondi,"carrière, mémoire collective, frictions, folklore, récits","Ce mémoire aborde les frictions générées par les sites dextraction de pierre calcaire en Wallonie.
Ces espaces à ciel ouvert qui creusent le paysage et réveillent des tensions entre exploitation
industrielle, transformations environnementales et vécus du vivant. Au-delà des plaies visibles,
ce travail cherche à faire entendre les silences des carrières, à travers des récits fragmentés
mêlant observations, entretiens et souvenirs personnels.",,,français,,,128 pages,,
2025-003,La berge du verre,,Yasmine Salem,yasminexsalem@gmail.com,"Nicolas Prignot, Olivier Gosselain","vidéo, objet éditorial",2025,,VI,Approfondi,"patrimoine, apprentissage, technique du vitrail, artisanat, documentaire,","Lorsque Pierre Majerus décède, il donne latelier de vitrail aux aspirants
maitres-verriers. Dailleurs, il disait : « apprendre un métier, cest le
voler à quelquun dautre ». Après son décès, Marcelle son épouse,
continuera à fabriquer les vitraux avec Vincent, le fidèle collaborateur.
Ensuite, Saskia a rejoint latelier pour donner des cours de vitraux avec
Vincent. Il y a aussi quelques verriers inconnus, le couple François
et Amélie et maintenant, Youri qui sessaie à la technique du vitrail à
latelier Pierre Majerus. Désormais, Marcelle et Vincent accompagnent
toutes les arrivées et départs de ces aspirants maitres-verriers. Sauf le
mardi et le mercredi, quand la salle de cours se remplit dapprenantes et
dapprenants. Vincent rejoint Saskia pour donner les cours de vitraux
avec les petits gâteaux et les « Alors ? Cest pas mal du tout ça ! »
Marcelle sort de sa véranda et se dirige vers la salle de cours avec ses
verres et ses outils dans un panier en osier. Devenir maitre-verrier ?
Ça sapprend toujours pour Marcelle malgré une soixantaine dannée à
coconstruire un atelier de vitrail.",,Dvd : avoir un lecteur dvd externe,français,,,78 pages + ?? minutes,,
2025-005,Reflet fantôme,"Mémoire, trauma et narration Le trauma comme plan cinématographique",Darika Peou,darika.peou@gmail.com,"Silvia Mesturini, Vanessa Frangville",objet éditorial,2025,LIENS,CA,Approfondi,"mémoire, trauma, narration, cambodge, film, héritage, animation, génocide","""Reflet fantôme"" interroge la manière dont le cinéma peut reconstruire un récit identitaire à partir dun héritage khmer traumatique et fragmenté. À travers trois films sur le génocide cambodgien, jexplore comment les choix narratifs et esthétiques traduisent le trauma et ouvrent un espace pour transmettre et réinventer la mémoire. Ce mémoire croise analyse filmique, études sur la mémoire traumatique et réflexion sur la transmission transgénérationnelle.",,,français,,,70 pages,,
2025-006,Tohu-bohu,Vol. 1 Nitescences Spectrales,Lilo Joris,lilo.joris@erg.school,"Caroline Godart, David Berliner",objet éditorial,2025,DPM,,Approfondi,"mémoire, amnésie, mort, médiation, rêve, utopie, narration, magazine, anthropologie, poésie","Ce mémoire est le deuxième volume de la revue poétique Tohu-Bohu, le premier étant Vol. 0 : LIridescence du Brouhaha.
Il tente de mélanger différents champs référentiels en les considérant de manière égale, tout en bien tenant compte de leurs spécificités. Il varie entre approche scientifique et approche plus poétique et sensible, proposant ainsi de faire apparaître le « poétique » à travers le « scientifique ».
Nitescences Spectrales vient poser la question de la médiation de nos mort·es. Comment est-ce quon {ré}agit aux mort·es ? Quest-ce quiels laissent derrière elleux ? Quest-ce quiels emmènent avec ellux ? Et quest-ce quon peut faire des éclats amnésiques qui restent après leur « départ » ? Ces questions demandent à comprendre comment nos récits se forment malgré leurs trous, ainsi que de comprendre comment lémotion se transforme (de manière sensible ou de manière utilitaire) au fil du temps et donc de revoir la construction même du temps. L'enjeu sous-jacent du mémoire est dobserver les effets que produisent nos dialogues avec nos mort·es que ce soit des personnes ou des moments afin de comprendre comment demain se met en place et les implications que nous y avons.",,,français,,,92 pages + annexes,,
2025-007,La Prophétie des Bâtards,Danser et faire re(co)nnaître au monde des corps qui ne comptent pas,Moriane Richard,moriane.richard@gmail.com,"Hélène Bernard, Flavio Rodrigo Orzari Ferreira, Valérie Piette",objet éditorial,2025,,IP,Approfondi,"art, danse, performance, cartographies somatiques du pouvoir, justice, médicine, transe","
Nous vivons une épidémie invisible : linflammation chronique des systèmes nerveux et sociaux sous leffet cumulé des traumatismes individuels et structurels (racisme, patriarcat, impérialisme, validisme). Cette inflammation traverse les chairs, défait les liens, atrophie lattention, durcit la pensée. LArt—et la danse en particulier—peut y répondre par des pratiques dattention et de reliance : danser, toucher, trembler pour ré-accorder le c.o.r.p.s.e.s.p.r.i.t, ré-ouvrir limaginaire et relancer la capacité relationnelle.
Je travaille en auto-ethnographie incarnée, depuis des expériences qui débordent le discours : douleurs chroniques, gestes dauto-défense somatique, apprentissages empiriques. Je croise pratiques somatiques (TRE®—tremblement neurogénique guidé, Body-Mind Centering®, contact-impro) et enquêtes théoriques (traumatologie, écologies affectives), pour élaborer une recherche-création qui pense avec le mouvement et par le toucher. Ce cadre a donné naissance à une méthode en devenir : la DarkDance—un protocole souple, subversif et sans dogme, qui considère muscles, fascias, organes et liquides comme des milieux pensants, et les engage dans une politique de la sensation.
Les enjeux sont de déplacer la critique des systèmes de domination dans les tissus mêmes des corps (cartographies somatiques du pouvoir). Et douvrir des espaces de re(co)naissance pour celles et ceux dont larchive est manquante—« les corps qui ne comptent pas ».",,,français,,,99 pages,,
2025-008,"L'école, le parlement et la cuisine",,Alice Néron,alice.neron@outlook.com,"Ayoh Kré Duchâtelet,Karolina Svobodova",objet éditorial,2025,,DN,Approfondi,"art participatif, assemblée, bruxelles, joie, conversation, affects, collectif, pouvoir d'agir","Lécole, le parlement et la cuisine revient sur trois situations dart participatif engagées à Bruxelles ces cinq dernières années à partir de mon expérience: Bodies of Knowledge, le Code du numérique et «Cuisiner (...)». Bodies of Knowledge («Lécole»), de lartiste performeuse Sarah Vanhee, est une «salle de classe» nomade installée dans l'espace public pour apprendre des savoirs de vie des habitant·es. Le Code du Numérique («le parlement»),porté par lasbl reconnue en éducation permanente Habitant·es des images, est un faux vrai code de loi écrit à partir de témoignages pour réglementer le numérique. Il s'écrit pendant les «parlements humains»: un outil d'animation qui met en scène une assemblée législative. Les ateliers «Cuisiner (...)» organisés avec Zeste Le Reste visent à cuisiner des problèmes collectifs en même temps quun repas.
Ce mémoire questionne les potentiels transformateurs de ces trois dispositifs. Comment peuvent-ils participer ou non à nous faire sentir plus capables ensemble et à faire émerger des capacités de sentir, de penser et dagir en commun ? Comment peuvent-ils, ou non, nous permettre (artistes et participant·es) de reprendre joyeusement prise sur des affects tristes ? Que nous font-ils faire ?
",,,français,,,160 pages en plusieurs brochures,,
2025-009,Santurantikuy LIVE,"Objets, gestes, transactions",LALESHKA SALAS SALAZAR,laleshka.salas@gmail.com,Ivo Provoost,objet éditorial,2025,LIENS,,Enseignement,"Performance, Pérou, Art Populaire, Hawkaypata, Connexions Partielles, Andes, Andean Culture, Human-Non human, Postcolonial Studies","Ce travail de fin détudes, intitulé Santurantikuy LIVE : Objets, gestes, transactions, constitue
une exploration scénique du caractère spatial et performatif de la foire Santurantikuy, à travers
trois axes dramatiques : les croyances animistes, le commerce, et les racines communautaires.
Instaurée au XVIe siècle par lÉglise catholique dans le cadre des mesures du Concile de Trente,
la foire du Santurantikuy avait pour objectif déradiquer les croyances andines en promouvant la
vente de figurines et de saints chrétiens. Cependant, cet objectif na jamais été entièrement at
teint.
Aujourdhui, la foire constitue un espace où la cosmovision andine et la religion chrétienne coexis
tent dans un réseau complexe de relations. Par ailleurs, des tensions persistent entre les enjeux
commerciaux et la préservation des traditions.
Le choix de ce sujet découle dun ancrage personnel : la foire du Santurantikuy représente une
tradition profondément significative pour moi, car elle fut un moment de partage avec ma grand
mère et ma tante. Elles évoquaient leurs souvenirs de la foire pendant que nous y étions, et cest
cette mémoire dun moment en train de se vivre qui ma donné envie de revisiter et explorer cet
espace à travers une approche performative et scénique.",,,français,,,170 pages,,
2025-011,Apprendre comme pratique de la liberté au XXIe siècle ?,,Zem Azem,a.zem@myyahoo.com,Stéphane Noël,audio,2025,,TY,Enseignement,"éducation critique, numérique, émancipation, bell hooks, Paulo Freire, co-création, savoirs situés, logiciel libre, pédagogie, transmission","Ce mémoire, sous forme de podcast en cinq épisodes,
explore la pensée de bell hooks et de Paulo Freire
à travers la question: qu est-ce qu apprendre comme pratique de la liberté au XXIe siècle? En partant des fondements
de la pédagogie critique, ce mémoire interroge la manière dont léducation peut encore aujourdhui être un outil démancipation, de dialogue et de transformation sociale.
Dans un monde traversé par le numérique, les algorithmes, la post-vérité et la marchandisation du savoir, la question devient: comment rendre l apprentissage à nouveau
collectif, sensible et politique? À travers des enquêtes,
des récits personnels, des références théoriques et des interviews dartistes, de chercheur·ses et de praticien·nes du libre, ce podcast propose de penser autrement lacte dapprendre, en dehors des logiques verticales
et productivistes.
Chaque épisode explore une facette de cette problématique, de léducation critique à la culture numérique, en passant
par la typographie comme outil de réappropriation
du langage. Ce projet se veut à la fois réflexion
et expérimentation, à la croisée de la pédagogie,
du design, du militantisme et des communs.",,Audio + annexes en objet éditorial (brochures pour la bibliographie etc),français,,,68 minutes,,
2024-026,DepNum,,Théophile Gervreau-Mercier,theophile.gervreaumercier@erg.school,Audrey Samson,site web,2024,,,Spécialisé,"blog, dépendance, numérique, personnelle, collective, informatique, technologies, autobiographie, addiction","Mon mémoire de Master à l'ERG est un blog autobiographique sur ma dépendance numérique. Chaque post mêle vécu personnel, questions et recherche. J'explore les dynamiques complexes de notre dépendance collective aux technologies numériques, en croisant expérience individuelle et réflexion systémique.",,,français,,,,,https://ils.bib.uclouvain.be/global/documents/3830452
2024-036,Les sons de ma maison,,ségolène Chateau,chateau.segolene@icloud.com,Sylvie bouteiller,Audio,2024,,,Spécialisé,"Sons, maison, bruit, extension de bâtiment, Recording","Ce travail sera constitué de 5 pièces sonores, résultant denregistrements effectués dans différentes pièces de ma maison.
Le but étant de prendre conscience de notre environnement sonore, et du fait que nous sommes des êtres entendants, sensibles et réceptifs aux bruits qui nous entourent.
Il consiste également à rendre de nouveau audibles ces sons, oubliés mais pourtant bien présents dans nos environnements personnels et familiers.",,,français,,,,,https://ils.bib.uclouvain.be/global/documents/3828970
2024-043,So You!,,Nascimo Clette,nascimo.clette@gmail.com,Alexander Schellow,vidéo,2024,,,Spécialisé,"Mémoire, cinéma amateur, archivage, rapport matérialiste au médium vidéo, pratique naïve","So You! est un film que j'ai réalisé en 2024 pour mon Mémoire de fin de Master à l'ERG en Vidéographie. Il s'agit d'un montage de rush que j'ai rassemblé sur un petit caméscope depuis que j'ai 8 ans. Je me suis intéressé au fonctionnement de la mémoire et au développement d'une archive personnelle au travers du médium vidéographique. Une image en entraîne une autre, au rythme des souvenirs qui remontent et au gré de ce qu'ils m'évoquent. Interroger le geste commun dun archivage personnel, comment ce geste est modelé par la technologie et comment produit-il des regards et relations uniques avec le monde. La mémoire crée des connexions incongrues a travers le temps et l'espace et nos archives sont précieuses, car elle permettent de s'approprier des micro-récits qui sont souvent réduits au silence.",,,français,,,,,https://ils.bib.uclouvain.be/global/documents/3830451
1
2 Identifiant Titre Sous-titre Auteur·ice(s) Contact Promoteur·ice(s) Format Année AP Orientation Finalité Mots-clés Synopsis Contexte Remarques Langue Autorisation License taille Points sur 20 lien BAIU
3 séparer par une virgule — max. 10
4 Column1 Column2 Column3 Column4 Column5 Column6 Column7 Column8 Column9 Column10 Column11 Column12 Column13 Column14 Column15 Column16 Column17 Column18 Column19 Column20
5 2025-002 Plus haut, la poussière ira plus loin Lucie Jacquet luciejacquetlucie@gmail.com Silvia Mesturini, Sasha Newell objet éditorial 2025 SC Approfondi carrière, mémoire collective, frictions, folklore, récits Ce mémoire aborde les frictions générées par les sites d’extraction de pierre calcaire en Wallonie. Ces espaces à ciel ouvert qui creusent le paysage et réveillent des tensions entre exploitation industrielle, transformations environnementales et vécus du vivant. Au-delà des plaies visibles, ce travail cherche à faire entendre les silences des carrières, à travers des récits fragmentés mêlant observations, entretiens et souvenirs personnels. français 128 pages
6 2025-003 La berge du verre Yasmine Salem yasminexsalem@gmail.com Nicolas Prignot, Olivier Gosselain vidéo, objet éditorial 2025 VI Approfondi patrimoine, apprentissage, technique du vitrail, artisanat, documentaire, Lorsque Pierre Majerus décède, il donne l’atelier de vitrail aux aspirants maitres-verriers. D’ailleurs, il disait : « apprendre un métier, c’est le voler à quelqu’un d’autre ». Après son décès, Marcelle son épouse, continuera à fabriquer les vitraux avec Vincent, le fidèle collaborateur. Ensuite, Saskia a rejoint l’atelier pour donner des cours de vitraux avec Vincent. Il y a aussi quelques verriers inconnus, le couple François et Amélie et maintenant, Youri qui s’essaie à la technique du vitrail à l’atelier Pierre Majerus. Désormais, Marcelle et Vincent accompagnent toutes les arrivées et départs de ces aspirants maitres-verriers. Sauf le mardi et le mercredi, quand la salle de cours se remplit d’apprenantes et d’apprenants. Vincent rejoint Saskia pour donner les cours de vitraux avec les petits gâteaux et les « Alors ? C’est pas mal du tout ça ! » Marcelle sort de sa véranda et se dirige vers la salle de cours avec ses verres et ses outils dans un panier en osier. Devenir maitre-verrier ? Ça s’apprend toujours pour Marcelle malgré une soixantaine d’année à coconstruire un atelier de vitrail. Dvd : avoir un lecteur dvd externe français 78 pages + ?? minutes
7 2025-005 Reflet fantôme Mémoire, trauma et narration – Le trauma comme plan cinématographique Darika Peou darika.peou@gmail.com Silvia Mesturini, Vanessa Frangville objet éditorial 2025 LIENS CA Approfondi mémoire, trauma, narration, cambodge, film, héritage, animation, génocide "Reflet fantôme" interroge la manière dont le cinéma peut reconstruire un récit identitaire à partir d’un héritage khmer traumatique et fragmenté. À travers trois films sur le génocide cambodgien, j’explore comment les choix narratifs et esthétiques traduisent le trauma et ouvrent un espace pour transmettre et réinventer la mémoire. Ce mémoire croise analyse filmique, études sur la mémoire traumatique et réflexion sur la transmission transgénérationnelle. français 70 pages
8 2025-006 Tohu-bohu Vol. 1 – Nitescences Spectrales Lilo Joris lilo.joris@erg.school Caroline Godart, David Berliner objet éditorial 2025 DPM Approfondi mémoire, amnésie, mort, médiation, rêve, utopie, narration, magazine, anthropologie, poésie Ce mémoire est le deuxième volume de la revue poétique Tohu-Bohu, le premier étant Vol. 0 : L’Iridescence du Brouhaha. Il tente de mélanger différents champs référentiels en les considérant de manière égale, tout en bien tenant compte de leurs spécificités. Il varie entre approche scientifique et approche plus poétique et sensible, proposant ainsi de faire apparaître le « poétique » à travers le « scientifique ». Nitescences Spectrales vient poser la question de la médiation de nos mort·es. Comment est-ce qu’on {ré}agit aux mort·es ? Qu’est-ce qu’iels laissent derrière elleux ? Qu’est-ce qu’iels emmènent avec ellux ? Et qu’est-ce qu’on peut faire des éclats amnésiques qui restent après leur « départ » ? Ces questions demandent à comprendre comment nos récits se forment malgré leurs trous, ainsi que de comprendre comment l’émotion se transforme (de manière sensible ou de manière utilitaire) au fil du temps – et donc de revoir la construction même du temps. L'enjeu sous-jacent du mémoire est d’observer les effets que produisent nos dialogues avec nos mort·es – que ce soit des personnes ou des moments – afin de comprendre comment demain se met en place et les implications que nous y avons. français 92 pages + annexes
9 2025-007 La Prophétie des Bâtards Danser et faire re(co)nnaître au monde des corps qui ne comptent pas Moriane Richard moriane.richard@gmail.com Hélène Bernard, Flavio Rodrigo Orzari Ferreira, Valérie Piette objet éditorial 2025 IP Approfondi art, danse, performance, cartographies somatiques du pouvoir, justice, médicine, transe Nous vivons une épidémie invisible : l’inflammation chronique des systèmes nerveux et sociaux sous l’effet cumulé des traumatismes individuels et structurels (racisme, patriarcat, impérialisme, validisme). Cette inflammation traverse les chairs, défait les liens, atrophie l’attention, durcit la pensée. L’Art—et la danse en particulier—peut y répondre par des pratiques d’attention et de reliance : danser, toucher, trembler pour ré-accorder le c.o.r.p.s.e.s.p.r.i.t, ré-ouvrir l’imaginaire et relancer la capacité relationnelle. Je travaille en auto-ethnographie incarnée, depuis des expériences qui débordent le discours : douleurs chroniques, gestes d’auto-défense somatique, apprentissages empiriques. Je croise pratiques somatiques (TRE®—tremblement neurogénique guidé, Body-Mind Centering®, contact-impro) et enquêtes théoriques (traumatologie, écologies affectives), pour élaborer une recherche-création qui pense avec le mouvement et par le toucher. Ce cadre a donné naissance à une méthode en devenir : la DarkDance—un protocole souple, subversif et sans dogme, qui considère muscles, fascias, organes et liquides comme des milieux pensants, et les engage dans une politique de la sensation. Les enjeux sont de déplacer la critique des systèmes de domination dans les tissus mêmes des corps (cartographies somatiques du pouvoir). Et d’ouvrir des espaces de re(co)naissance pour celles et ceux dont l’archive est manquante—« les corps qui ne comptent pas ». français 99 pages
10 2025-008 L'école, le parlement et la cuisine Alice Néron alice.neron@outlook.com Ayoh Kré Duchâtelet,Karolina Svobodova objet éditorial 2025 DN Approfondi art participatif, assemblée, bruxelles, joie, conversation, affects, collectif, pouvoir d'agir L’école, le parlement et la cuisine revient sur trois situations d’art participatif engagées à Bruxelles ces cinq dernières années à partir de mon expérience: Bodies of Knowledge, le Code du numérique et «Cuisiner (...)». Bodies of Knowledge («L’école»), de l’artiste performeuse Sarah Vanhee, est une «salle de classe» nomade installée dans l'espace public pour apprendre des savoirs de vie des habitant·es. Le Code du Numérique («le parlement»),porté par l’asbl reconnue en éducation permanente Habitant·es des images, est un faux vrai code de loi écrit à partir de témoignages pour réglementer le numérique. Il s'écrit pendant les «parlements humains»: un outil d'animation qui met en scène une assemblée législative. Les ateliers «Cuisiner (...)» organisés avec Zeste Le Reste visent à cuisiner des problèmes collectifs en même temps qu’un repas. Ce mémoire questionne les potentiels transformateurs de ces trois dispositifs. Comment peuvent-ils participer ou non à nous faire sentir plus capables ensemble et à faire émerger des capacités de sentir, de penser et d’agir en commun ? Comment peuvent-ils, ou non, nous permettre (artistes et participant·es) de reprendre joyeusement prise sur des affects tristes ? Que nous font-ils faire ? français 160 pages en plusieurs brochures
11 2025-009 Santurantikuy LIVE Objets, gestes, transactions LALESHKA SALAS SALAZAR laleshka.salas@gmail.com Ivo Provoost objet éditorial 2025 LIENS Enseignement Performance, Pérou, Art Populaire, Hawkaypata, Connexions Partielles, Andes, Andean Culture, Human-Non human, Postcolonial Studies Ce travail de fin d’études, intitulé Santurantikuy LIVE : Objets, gestes, transactions, constitue une exploration scénique du caractère spatial et performatif de la foire Santurantikuy, à travers trois axes dramatiques : les croyances animistes, le commerce, et les racines communautaires. Instaurée au XVIe siècle par l’Église catholique dans le cadre des mesures du Concile de Trente, la foire du Santurantikuy avait pour objectif d’éradiquer les croyances andines en promouvant la vente de figurines et de saints chrétiens. Cependant, cet objectif n’a jamais été entièrement at teint. Aujourd’hui, la foire constitue un espace où la cosmovision andine et la religion chrétienne coexis tent dans un réseau complexe de relations. Par ailleurs, des tensions persistent entre les enjeux commerciaux et la préservation des traditions. Le choix de ce sujet découle d’un ancrage personnel : la foire du Santurantikuy représente une tradition profondément significative pour moi, car elle fut un moment de partage avec ma grand mère et ma tante. Elles évoquaient leurs souvenirs de la foire pendant que nous y étions, et c’est cette mémoire d’un moment en train de se vivre qui m’a donné envie de revisiter et explorer cet espace à travers une approche performative et scénique. français 170 pages
12 2025-011 Apprendre comme pratique de la liberté au XXIe siècle ? Zem Azem a.zem@myyahoo.com Stéphane Noël audio 2025 TY Enseignement éducation critique, numérique, émancipation, bell hooks, Paulo Freire, co-création, savoirs situés, logiciel libre, pédagogie, transmission Ce mémoire, sous forme de podcast en cinq épisodes, explore la pensée de bell hooks et de Paulo Freire à travers la question : qu’ est-ce qu’ apprendre comme pratique de la liberté au XXIe siècle ? En partant des fondements de la pédagogie critique, ce mémoire interroge la manière dont l’éducation peut encore aujourd’hui être un outil d’émancipation, de dialogue et de transformation sociale. Dans un monde traversé par le numérique, les algorithmes, la post-vérité et la marchandisation du savoir, la question devient : comment rendre l’ apprentissage à nouveau collectif, sensible et politique ? À travers des enquêtes, des récits personnels, des références théoriques et des interviews d’artistes, de chercheur·ses et de praticien·nes du libre, ce podcast propose de penser autrement l’acte d’apprendre, en dehors des logiques verticales et productivistes. Chaque épisode explore une facette de cette problématique, de l’éducation critique à la culture numérique, en passant par la typographie comme outil de réappropriation du langage. Ce projet se veut à la fois réflexion et expérimentation, à la croisée de la pédagogie, du design, du militantisme et des communs. Audio + annexes en objet éditorial (brochures pour la bibliographie etc) français 68 minutes
13 2024-026 DepNum Théophile Gervreau-Mercier theophile.gervreaumercier@erg.school Audrey Samson site web 2024 Spécialisé blog, dépendance, numérique, personnelle, collective, informatique, technologies, autobiographie, addiction Mon mémoire de Master à l'ERG est un blog autobiographique sur ma dépendance numérique. Chaque post mêle vécu personnel, questions et recherche. J'explore les dynamiques complexes de notre dépendance collective aux technologies numériques, en croisant expérience individuelle et réflexion systémique. français https://ils.bib.uclouvain.be/global/documents/3830452
14 2024-036 Les sons de ma maison ségolène Chateau chateau.segolene@icloud.com Sylvie bouteiller Audio 2024 Spécialisé Sons, maison, bruit, extension de bâtiment, Recording Ce travail sera constitué de 5 pièces sonores, résultant d’enregistrements effectués dans différentes pièces de ma maison. Le but étant de prendre conscience de notre environnement sonore, et du fait que nous sommes des êtres entendants, sensibles et réceptifs aux bruits qui nous entourent. Il consiste également à rendre de nouveau audibles ces sons, oubliés mais pourtant bien présents dans nos environnements personnels et familiers. français https://ils.bib.uclouvain.be/global/documents/3828970
15 2024-043 So You! Nascimo Clette nascimo.clette@gmail.com Alexander Schellow vidéo 2024 Spécialisé Mémoire, cinéma amateur, archivage, rapport matérialiste au médium vidéo, pratique naïve So You! est un film que j'ai réalisé en 2024 pour mon Mémoire de fin de Master à l'ERG en Vidéographie. Il s'agit d'un montage de rush que j'ai rassemblé sur un petit caméscope depuis que j'ai 8 ans. Je me suis intéressé au fonctionnement de la mémoire et au développement d'une archive personnelle au travers du médium vidéographique. Une image en entraîne une autre, au rythme des souvenirs qui remontent et au gré de ce qu'ils m'évoquent. Interroger le geste commun d’un archivage personnel, comment ce geste est modelé par la technologie et comment produit-il des regards et relations uniques avec le monde. La mémoire crée des connexions incongrues a travers le temps et l'espace et nos archives sont précieuses, car elle permettent de s'approprier des micro-récits qui sont souvent réduits au silence. français https://ils.bib.uclouvain.be/global/documents/3830451

222
app/storage/README.md Normal file
View File

@@ -0,0 +1,222 @@
# Database Documentation
Complete documentation for the Post-ERG thesis database.
## 📚 Available Documentation
### 1. **[DATABASE_SPECIFICATION.md](DATABASE_SPECIFICATION.md)** ⭐
**Complete technical specification** - 25KB comprehensive document
**Contents:**
- Complete table definitions with all columns
- Entity relationship diagrams
- Junction table specifications
- Lookup table values
- Business rules and workflows
- Sample queries and use cases
- Instructions for requesting schema changes
**Use when:** You need complete technical details about the database structure.
---
### 2. **[QUICK_SCHEMA_REFERENCE.md](QUICK_SCHEMA_REFERENCE.md)** 🚀
**Quick reference guide** - 5KB at-a-glance reference
**Contents:**
- Table summary
- Key relationships diagram
- Core fields reference
- Predefined lookup values
- Common SQL queries
- Constraint summary
**Use when:** You need quick lookup or common query examples.
---
### 3. **[schema.sql](schema.sql)** 💾
**The actual SQL schema** - Executable SQL file
**Contents:**
- Complete CREATE TABLE statements
- Indexes and triggers
- Predefined data (orientations, AP programs, etc.)
- Views for common queries
**Use when:** Setting up or resetting the database.
---
## 🚀 Quick Start
### View Database Schema
```bash
# Read the quick reference
cat database/QUICK_SCHEMA_REFERENCE.md
# Or full specification
cat database/DATABASE_SPECIFICATION.md
```
### Initialize Database
```bash
# Create test database from schema
just init-test-db
# Create with sample data
just create-fixtures
```
### Query Database
```bash
# Open SQLite prompt
just query-db
# Show specific thesis
just show-thesis 42
```
## 📝 Making Schema Changes
### Step 1: Document Your Request
Format:
```
**Table:** [table_name]
**Change Type:** [add/modify/remove]
**What:** [description]
**Why:** [reason/use case]
**Example Data:** [samples]
```
### Step 2: Specify Details
For **new columns**:
- Column name
- Data type (TEXT, INTEGER, BOOLEAN, DATETIME)
- NULL/NOT NULL
- Default value
- Indexes needed?
For **new tables**:
- Table name
- All columns
- Relationships to existing tables
- Sample data
### Step 3: Provide Context
Include:
- Use case scenario
- Who will use it?
- How will it be displayed?
- Any constraints?
### Example Request
```
**Table:** theses
**Change Type:** add column
**What:** Add column to track if thesis won an award
**Why:** Need to highlight award-winning theses on homepage
**Column Name:** has_award
**Data Type:** BOOLEAN
**Default:** 0 (false)
**Example:** 1 for "Prix du Jury 2025" winner
```
## 🗂️ Database Structure Overview
```
┌─────────────┐
│ theses │ ◄── Main table (500+ records/year)
└──────┬──────┘
├──► authors (via thesis_authors)
├──► supervisors (via thesis_supervisors)
├──► keywords (via thesis_keywords)
├──► languages (via thesis_languages)
├──► formats (via thesis_formats)
├──► thesis_files (attachments)
└──► Lookup tables:
• orientations
• ap_programs
• finality_types
• access_types
• license_types
```
## 📊 Key Statistics
- **Core tables:** 3 (theses, authors, supervisors)
- **Junction tables:** 5 (many-to-many relationships)
- **Lookup tables:** 7 (predefined values)
- **Support tables:** 2 (files, pages)
- **Views:** 2 (full data, public only)
- **Indexes:** 11 (for performance)
- **Triggers:** 4 (auto-update timestamps)
## 🔍 Common Scenarios
### Scenario 1: Student Submits Thesis
1. Create record in `theses` (is_published=0)
2. Add author to `authors`, link via `thesis_authors`
3. Add supervisor(s) to `supervisors`, link via `thesis_supervisors`
4. Set `orientation_id`, `ap_program_id`, `finality_id`
5. Upload file to `thesis_files`
6. Add keywords via `thesis_keywords`
7. Set `submitted_at` timestamp
### Scenario 2: Admin Publishes Thesis
1. Verify all required fields present
2. Set `defense_date`
3. Set `jury_points`
4. Optional: add `context_note`
5. Set `is_published = 1`
6. Set `published_at = CURRENT_TIMESTAMP`
### Scenario 3: Public User Searches
Query `v_theses_public` view with filters:
- By year
- By orientation
- By keyword
- By author name
- Full-text search in title/synopsis
## 🛠️ Development Workflow
### Local Development
1. Use `test.db` for development
2. Create via `just init-test-db`
3. Populate with `just create-fixtures`
4. Test queries before deployment
### Schema Changes
1. Update `schema.sql`
2. Update `DATABASE_SPECIFICATION.md`
3. Test on `test.db`
4. Deploy to production (manual migration)
### Testing
```bash
# Run tests on local database
just test-public-all
# Check database stats
just stats-public
```
## 📞 Need Help?
1. **Quick lookup** → Read `QUICK_SCHEMA_REFERENCE.md`
2. **Complete details** → Read `DATABASE_SPECIFICATION.md`
3. **Schema changes** → Follow format in this README
4. **SQL examples** → Check `QUICK_SCHEMA_REFERENCE.md`
## 🔗 Related Documentation
- [Deployment Guide](../nginx/DEPLOYMENT_COMPLETE.md)
- [Repository Structure](../REPOSITORY_STRUCTURE_ANALYSIS.md)
- [Test Database Guide](../nginx/TEST_DATABASE_SETUP.md)

Some files were not shown because too many files have changed in this diff Show More