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

22
DEV.md
View File

@@ -1,22 +0,0 @@
# Mise en place Dev
## MacOS
Logiciels:
- un IDE pour éditer → VSCode
- git (ou une interface graphique) pour partager les modifications → git-gui (officiel) ou Github Desktop
- un server web avec PHP pour visualiser le project dans le navigateur → MAMP
## Workflow
1. Faire un changement dans ton IDE
2. Démarrer le site via MAMP, en sélectionnant le dossier `public`
3. Vérifier que ça marche sur le site en local, depuis ton navigateur
4. Une fois qu'un changement spécifique est fait, `commit` les changements sur les fichiers qui sont relatif à ce changement
5. Vérifier que vous avez syncroniser avec le `remote``pull` + `rebase` ! pas merge
6. `push` les changements vers le remote

View File

@@ -1,4 +1,6 @@
# posterg # XAMXAM
(Anciennement *Posterg*)
Répertoire des travaux de fin d'études de l'[ERG](https://erg.be) (École de Recherche Graphique). Répertoire des travaux de fin d'études de l'[ERG](https://erg.be) (École de Recherche Graphique).
@@ -8,39 +10,6 @@ Répertoire des travaux de fin d'études de l'[ERG](https://erg.be) (École de R
- SQLite3 (`php8.4-sqlite3`) - SQLite3 (`php8.4-sqlite3`)
- nginx (production) - nginx (production)
## Project structure
```
posterg/
├── public/ # DocumentRoot — web-accessible only
│ ├── admin/ # Admin panel (session-authenticated)
│ ├── assets/ # CSS, fonts, icons
│ ├── media.php # Controlled file serving (covers, PDFs)
│ └── *.php # Public pages (index, search, tfe, apropos)
├── src/ # PHP classes (not web-accessible)
│ ├── AdminAuth.php
│ ├── Database.php
│ ├── RateLimit.php
│ └── config.php
├── templates/ # Shared PHP template partials
├── config/ # Bootstrap and credentials (not web-accessible)
├── storage/ # Database and uploaded files (not web-accessible)
│ ├── schema.sql
│ ├── test.db
│ └── fixtures/
├── tests/
├── scripts/ # Dev and server management scripts
│ ├── setup-dev.sh
│ ├── deploy-server.sh # Run on server with sudo to apply nginx config
│ └── manage-admin-users.sh # Run on server with sudo to manage htpasswd
└── nginx/ # nginx config and reference files
├── posterg.conf
└── docs/ # Documentation
```
Uploaded files (PDFs, covers) live in `storage/` — outside the webroot — and are
served exclusively through `public/media.php`, which validates paths and MIME types.
## Development ## Development
```bash ```bash
@@ -95,3 +64,24 @@ ssh posterg "sudo bash /tmp/manage-admin-users.sh"
- Uploads stored outside webroot, served via controlled `media.php` - Uploads stored outside webroot, served via controlled `media.php`
- Rate limiting on public search (`src/RateLimit.php`) - Rate limiting on public search (`src/RateLimit.php`)
- See `nginx/docs/SECURITY_HEADERS.md` for security headers reference - See `nginx/docs/SECURITY_HEADERS.md` for security headers reference
## Mise en place Dev
### MacOS
Logiciels:
- un IDE pour éditer → VSCode
- git (ou une interface graphique) pour partager les modifications → git-gui (officiel) ou Github Desktop
- un server web avec PHP pour visualiser le project dans le navigateur → MAMP
### Workflow
0. Faire un changement dans ton IDE
1. Démarrer le site via MAMP, en sélectionnant le dossier `public`
2. Vérifier que ça marche sur le site en local, depuis ton navigateur
3. Une fois qu'un changement spécifique est fait, `commit` les changements sur les fichiers qui sont relatif à ce changement
4. Vérifier que vous avez syncroniser avec le `remote``pull` + `rebase` ! pas merge
5. `push` les changements vers le remote

64
TODO.md
View File

@@ -1,15 +1,53 @@
# TODO # TODO
- [x] Create migration 010_apropos_contents.sql (apropos_contents table, seed defaults) - [x] Replace inline alert CSS in admin.css with floating bottom-center toast styles (fixed, z-index, animation)
- [x] Add apropos CRUD methods to Database.php - [x] Update flash-messages.php partial to output `.toast` markup in hidden container for footer JS
- [x] Create admin/contenus.php (replaces pages.php) - [x] Add toast container HTML + JS to admin footer.php (centralised, 4s auto-dismiss with fade-out)
- [x] Create admin/contenus-edit.php (edit pages + apropos contacts/credits/erg_url) - [x] Remove redundant flash-messages.php includes from all admin pages (8 files)
- [x] Create admin/actions/apropos.php - save handler for apropos contents - [x] Convert hardcoded alerts in login.php, thanks.php, index.php import to `.toast` class
- [x] Update templates/header.php: rename "Pages statiques" → "Contenus", update nav links - [x] Update admin.css dialog rule from `[role=alert/status]` to `.toast`
- [x] Update public/apropos.php: read contacts/credits/erg_url from DB instead of config - [x] Commit with jj
- [x] Delete config/apropos.php
- [x] Delete public/admin/pages.php - [x] Move DB export from admin/index.php to admin/parametres.php (maintenance section)
- [x] Delete public/admin/pages-edit.php
- [x] Delete public/admin/actions/page.php - [x] Reorganize src/ - move 7 controllers to src/Controllers/
- [x] Update storage/schema.sql with apropos_contents table + trigger - [x] Create Controllers directory
- [x] Rework system.php/system.js: replace custom fetch() JS with HTMX, inline onclick for copy + collapse - [x] Move controller files (Home, Tfe, Search, ThesisCreate, ThesisEdit, Export, System)
- [x] Update all require_once paths across codebase
- [x] Move stray test.db from root to storage/
- [x] Store admin password hash in DB (site_settings) instead of config file
- [x] Create migration 013
- [x] Update AdminAuth to read hash from DB
- [x] Update bootstrap.php — remove credential file loading
- [x] Update parametres.php — status check from DB
- [x] Update actions/account.php — write hash to DB
- [x] Update login.php — dev-mode check
- [x] Update header.php — dev check
- [x] Delete config/admin_credentials.example.php
## Now: Single Entry Point Routing
### Phase 1: Dispatcher refinement
- [x] MediaController: extract media.php logic into MediaController class
- [x] Create src/Controllers/MediaController.php
- [x] Move path validation + storage jail + MIME check + streaming
- [x] Wire into Dispatcher for /media route
- [x] Delete app/public/media.php
- [ ] Update Dispatcher to handle all routes directly (no require APP_ROOT/public/*.php)
### Phase 2: Single entry point
- [ ] Create app/public/index.php as front controller
- [ ] Bootstrap + Dispatcher invocation
- [ ] Remove direct-access public/*.php (index.php, search.php, tfe.php, apropos.php, licence.php)
- [ ] Rename old entry points so they can't be hit directly (e.g., prefix with underscore or delete)
### Phase 3: Server config
- [ ] Update router.php — route all PHP requests to Dispatcher
- [ ] Update nginx config — point all public routes to index.php via try_files
- [ ] Replace per-file `location ~ \.php$` with front-controller pattern
### Phase 4: Cleanup
- [ ] Delete app/public/live-reload.php (already handled by LiveReloadController)
- [ ] Test all routes (/, search.php, tfe, repertoire, apropos, licence, media, live-reload)

View File

@@ -5,11 +5,11 @@
*/ */
// Define application root // Define application root
define('APP_ROOT', dirname(__DIR__)); define('APP_ROOT', __DIR__);
// Storage directory for uploaded files — intentionally outside the webroot // Storage directory for uploaded files — intentionally outside the webroot
// so no uploaded content is ever directly web-accessible (items #3 & #4). // so no uploaded content is ever directly web-accessible (items #3 & #4).
// Files are served through public/media.php which validates paths and MIME types. // Files are served through MediaController which validates paths and MIME types.
define('STORAGE_ROOT', '/var/www/posterg/storage'); define('STORAGE_ROOT', '/var/www/posterg/storage');
// Error reporting // Error reporting
@@ -24,10 +24,8 @@ if (php_sapi_name() === 'cli-server') {
ini_set('log_errors', '1'); ini_set('log_errors', '1');
} }
// Load admin credentials if available (defines ADMIN_PASSWORD_HASH for AdminAuth) // Admin password hash is stored in site_settings (DB).
if (file_exists(APP_ROOT . '/config/admin_credentials.php')) { // AdminAuth reads it on demand — no static config file needed.
require_once APP_ROOT . '/config/admin_credentials.php';
}
// Central application helper (boot, auth guard, CSRF, flash, render) // Central application helper (boot, auth guard, CSRF, flash, render)
require_once APP_ROOT . '/src/App.php'; require_once APP_ROOT . '/src/App.php';

View File

@@ -1,5 +1,5 @@
<?php <?php
require_once __DIR__ . '/../../config/bootstrap.php'; require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php'; require_once __DIR__ . '/../../src/AdminAuth.php';
require_once __DIR__ . '/../../src/ShareLink.php'; require_once __DIR__ . '/../../src/ShareLink.php';
@@ -18,7 +18,7 @@ $bodyClass = 'admin-body';
<?php include APP_ROOT . '/templates/header.php'; ?> <?php include APP_ROOT . '/templates/header.php'; ?>
<main id="main-content"> <main id="main-content">
<?php include APP_ROOT . '/templates/partials/flash-messages.php'; ?>
<div class="admin-list-toolbar"> <div class="admin-list-toolbar">
<h1>Accès étudiant·e</h1> <h1>Accès étudiant·e</h1>

View File

@@ -1,12 +1,11 @@
<?php <?php
require_once __DIR__ . "/../../config/bootstrap.php"; require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php'; require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin(); AdminAuth::requireLogin();
$pageTitle = "Compte administrateur"; $pageTitle = "Compte administrateur";
$credentialsFile = APP_ROOT . '/config/admin_credentials.php'; $hasPassword = AdminAuth::hasPassword();
$hasPassword = defined('ADMIN_PASSWORD_HASH');
// Flash messages are consumed by the flash-messages partial below. // Flash messages are consumed by the flash-messages partial below.
@@ -20,7 +19,7 @@ if (empty($_SESSION['csrf_token'])) {
<main id="main-content"> <main id="main-content">
<h1>Compte administrateur</h1> <h1>Compte administrateur</h1>
<?php include APP_ROOT . '/templates/partials/flash-messages.php'; ?>
<!-- Status info --> <!-- Status info -->
<dl class="admin-account-status"> <dl class="admin-account-status">
@@ -29,16 +28,16 @@ if (empty($_SESSION['csrf_token'])) {
<dd><?php $badgeType = 'ok'; $badgeValue = $hasPassword; $badgeOkLabel = 'Active'; $badgeWarnLabel = 'Non configurée'; include APP_ROOT . '/templates/partials/status-badge.php'; ?></dd> <dd><?php $badgeType = 'ok'; $badgeValue = $hasPassword; $badgeOkLabel = 'Active'; $badgeWarnLabel = 'Non configurée'; include APP_ROOT . '/templates/partials/status-badge.php'; ?></dd>
</div> </div>
<div class="admin-account-status__row"> <div class="admin-account-status__row">
<dt class="admin-account-status__label">Fichier de configuration</dt> <dt class="admin-account-status__label">Stockage</dt>
<dd> <dd>
<code class="admin-account-status__code">config/admin_credentials.php</code> <code class="admin-account-status__code">site_settings (DB)</code>
<?php $badgeType = 'ok'; $badgeValue = file_exists($credentialsFile); $badgeOkLabel = 'Présent'; $badgeWarnLabel = 'Absent'; include APP_ROOT . '/templates/partials/status-badge.php'; ?> <?php $badgeType = 'ok'; $badgeValue = $hasPassword; $badgeOkLabel = 'Présent'; $badgeWarnLabel = 'Absent'; include APP_ROOT . '/templates/partials/status-badge.php'; ?>
</dd> </dd>
</div> </div>
<?php if (!$hasPassword): ?> <?php if (!$hasPassword): ?>
<p class="admin-account-status__note"> <p class="admin-account-status__note">
Aucun mot de passe PHP configuré. Le formulaire ci-dessous créera Aucun mot de passe PHP configuré. Le formulaire ci-dessous stockera
<code>config/admin_credentials.php</code> avec un hash bcrypt. un hash bcrypt dans la base de données.
</p> </p>
<?php endif; ?> <?php endif; ?>
</dl> </dl>
@@ -91,16 +90,16 @@ if (empty($_SESSION['csrf_token'])) {
<p class="admin-danger-zone__description"> <p class="admin-danger-zone__description">
<strong>Supprimer la configuration du mot de passe PHP</strong><br> <strong>Supprimer la configuration du mot de passe PHP</strong><br>
<small> <small>
Supprime <code>config/admin_credentials.php</code>. L'accès admin Supprime le hash de la base de données. L'accès admin
dépendra uniquement de l'authentification nginx Basic Auth si elle est configurée. dépendra uniquement de l'authentification nginx Basic Auth si elle est configurée.
</small> </small>
</p> </p>
<form method="post" action="/admin/actions/account.php" <form method="post" action="/admin/actions/account.php"
onsubmit="return confirm('Supprimer le fichier de mot de passe PHP ? L\'accès admin ne sera protégé que par nginx Basic Auth.')"> onsubmit="return confirm('Supprimer le mot de passe 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="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="remove_credentials"> <input type="hidden" name="action" value="remove_credentials">
<input type="hidden" name="current_password_remove" id="current_password_remove" value=""> <input type="hidden" name="current_password_remove" id="current_password_remove" value="">
<button type="submit" class="admin-btn admin-btn--danger">Supprimer le fichier</button> <button type="submit" class="admin-btn admin-btn--danger">Supprimer</button>
</form> </form>
</div> </div>
<?php endif; ?> <?php endif; ?>

View File

@@ -2,7 +2,7 @@
/** /**
* Student-access link actions (create, toggle, set_password, delete). * Student-access link actions (create, toggle, set_password, delete).
*/ */
require_once __DIR__ . '/../../../config/bootstrap.php'; require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php'; require_once __DIR__ . '/../../../src/AdminAuth.php';
require_once __DIR__ . '/../../../src/ShareLink.php'; require_once __DIR__ . '/../../../src/ShareLink.php';

View File

@@ -1,14 +1,16 @@
<?php <?php
/** /**
* Admin account action update or remove admin_credentials.php * Admin account action update or remove admin password.
* *
* Actions: * Actions:
* POST (default) set/change the PHP admin password * POST (default) set/change the PHP admin password
* POST action=remove_credentials delete admin_credentials.php * POST action=remove_credentials remove the password from DB
*/ */
require_once __DIR__ . '/../../../config/bootstrap.php'; require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php'; require_once __DIR__ . '/../../../src/AdminAuth.php';
require_once __DIR__ . '/../../../src/Database.php';
AdminAuth::requireLogin(); AdminAuth::requireLogin();
// ── CSRF ────────────────────────────────────────────────────────────────────── // ── CSRF ──────────────────────────────────────────────────────────────────────
@@ -17,37 +19,25 @@ if (empty($_SESSION['csrf_token']) || ($_POST['csrf_token'] ?? '') !== $_SESSION
die('Invalid CSRF token.'); die('Invalid CSRF token.');
} }
$credentialsFile = APP_ROOT . '/config/admin_credentials.php'; $hasPassword = AdminAuth::hasPassword();
$hasPassword = defined('ADMIN_PASSWORD_HASH');
$action = $_POST['action'] ?? 'change_password'; $action = $_POST['action'] ?? 'change_password';
// ── Remove credentials ──────────────────────────────────────────────────────── // ── Remove credential ────────────────────────────────────────────────────────
if ($action === 'remove_credentials') { if ($action === 'remove_credentials') {
$backUrl = $_POST['redirect'] ?? '/admin/parametres.php'; $backUrl = $_POST['redirect'] ?? '/admin/parametres.php';
if (!preg_match('#^/admin/#', $backUrl)) { $backUrl = '/admin/parametres.php'; } if (!preg_match('#^/admin/#', $backUrl)) { $backUrl = '/admin/parametres.php'; }
if (!$hasPassword) { if (!$hasPassword) {
App::flash('error', 'Aucun fichier de mot de passe à supprimer.'); App::flash('error', 'Aucun mot de passe à supprimer.');
header('Location: ' . $backUrl); header('Location: ' . $backUrl);
exit; exit;
} }
if (!is_writable($credentialsFile) && !is_writable(dirname($credentialsFile))) { AdminAuth::removePasswordHash();
App::flash('error', 'Le fichier de configuration n\'est pas accessible en écriture.'); // Destroy session so the user is forced to re-authenticate.
header('Location: ' . $backUrl); AdminAuth::logout();
exit; header('Location: /admin/login.php');
} exit;
if (@unlink($credentialsFile)) {
// Destroy session so the user is forced to re-authenticate via nginx Basic Auth.
AdminAuth::logout();
header('Location: /admin/login.php');
exit;
} else {
App::flash('error', 'Impossible de supprimer le fichier de configuration.');
header('Location: ' . $backUrl);
exit;
}
} }
// ── Change / set password ───────────────────────────────────────────────────── // ── Change / set password ─────────────────────────────────────────────────────
@@ -89,32 +79,8 @@ if ($hash === false) {
exit; exit;
} }
// 4. Write credentials file. // 4. Store hash in DB.
$configContent = '<?php' . "\n" AdminAuth::setPasswordHash($hash);
. '/**' . "\n"
. ' * Admin PHP-level password for the session auth guard (defence-in-depth).' . "\n"
. ' *' . "\n"
. ' * Generated by the admin panel on ' . date('Y-m-d H:i:s') . '.' . "\n"
. ' * To regenerate manually:' . "\n"
. ' * php -r "echo password_hash(\'your-password\', PASSWORD_BCRYPT, [\'cost\' => 12]);"' . "\n"
. ' */' . "\n"
. "\n"
. 'define(\'ADMIN_PASSWORD_HASH\', ' . var_export($hash, true) . ');' . "\n";
// Write atomically via a temp file.
$tmpFile = $credentialsFile . '.tmp.' . bin2hex(random_bytes(6));
if (file_put_contents($tmpFile, $configContent, LOCK_EX) === false) {
@unlink($tmpFile);
App::flash('error', 'Impossible d\'écrire le fichier de configuration. Vérifiez les permissions sur config/.');
header('Location: ' . $backUrl);
exit;
}
if (!rename($tmpFile, $credentialsFile)) {
@unlink($tmpFile);
App::flash('error', 'Impossible de mettre à jour le fichier de configuration.');
header('Location: ' . $backUrl);
exit;
}
// 5. Regenerate session (password changed — invalidate old sessions). // 5. Regenerate session (password changed — invalidate old sessions).
session_regenerate_id(true); session_regenerate_id(true);

View File

@@ -3,7 +3,7 @@
* Save handler for apropos contents (contacts, credits). * Save handler for apropos contents (contacts, credits).
* Structure: groups[] with label/role, each having entries[] of {text, url, email}. * Structure: groups[] with label/role, each having entries[] of {text, url, email}.
*/ */
require_once __DIR__ . "/../../../config/bootstrap.php"; require_once __DIR__ . "/../../../bootstrap.php";
require_once __DIR__ . '/../../../src/AdminAuth.php'; require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin(); AdminAuth::requireLogin();

View File

@@ -1,5 +1,5 @@
<?php <?php
require_once __DIR__ . '/../../../config/bootstrap.php'; require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php'; require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin(); AdminAuth::requireLogin();

View File

@@ -1,6 +1,6 @@
<?php <?php
// Bootstrap application // Bootstrap application
require_once __DIR__ . "/../../../config/bootstrap.php"; require_once __DIR__ . "/../../../bootstrap.php";
require_once __DIR__ . '/../../../src/AdminAuth.php'; require_once __DIR__ . '/../../../src/AdminAuth.php';
// PHP-level auth guard (defence-in-depth behind nginx Basic Auth) // PHP-level auth guard (defence-in-depth behind nginx Basic Auth)
@@ -24,7 +24,7 @@ if ($thesisId <= 0) {
die("ID de TFE invalide."); die("ID de TFE invalide.");
} }
require_once APP_ROOT . '/src/ThesisEditController.php'; require_once APP_ROOT . '/src/Controllers/ThesisEditController.php';
try { try {
$ctrl = ThesisEditController::create(); $ctrl = ThesisEditController::create();

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

@@ -1,6 +1,6 @@
<?php <?php
// Bootstrap application // Bootstrap application
require_once __DIR__ . '/../../../config/bootstrap.php'; require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php'; require_once __DIR__ . '/../../../src/AdminAuth.php';
ini_set('display_errors', 0); ini_set('display_errors', 0);
@@ -18,7 +18,7 @@ if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
error_log('FILES array: ' . print_r($_FILES, true)); error_log('FILES array: ' . print_r($_FILES, true));
require_once APP_ROOT . '/src/ThesisCreateController.php'; require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php';
try { try {
$ctrl = ThesisCreateController::make(); $ctrl = ThesisCreateController::make();

View File

@@ -1,5 +1,5 @@
<?php <?php
require_once __DIR__ . '/../../../config/bootstrap.php'; require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php'; require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin(); AdminAuth::requireLogin();

View File

@@ -1,5 +1,5 @@
<?php <?php
require_once __DIR__ . '/../../../config/bootstrap.php'; require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php'; require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin(); AdminAuth::requireLogin();

View File

@@ -1,5 +1,5 @@
<?php <?php
require_once __DIR__ . '/../../../config/bootstrap.php'; require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php'; require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin(); AdminAuth::requireLogin();
@@ -11,6 +11,7 @@ if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
} }
require_once APP_ROOT . '/src/Database.php'; require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/SmtpRelay.php';
$db = new Database(); $db = new Database();
$section = $_POST['section'] ?? ''; $section = $_POST['section'] ?? '';
@@ -23,6 +24,22 @@ if ($section === 'formulaire') {
$db->setSetting($key, $value); $db->setSetting($key, $value);
} }
App::flash('success', "Paramètres du formulaire mis à jour."); 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 { } else {
App::flash('error', "Section inconnue."); App::flash('error', "Section inconnue.");
} }

View File

@@ -1,5 +1,5 @@
<?php <?php
require_once __DIR__ . '/../../../config/bootstrap.php'; require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php'; require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin(); AdminAuth::requireLogin();

View File

@@ -1,5 +1,5 @@
<?php <?php
require_once __DIR__ . '/../../../config/bootstrap.php'; require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php'; require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin(); AdminAuth::requireLogin();

View File

@@ -1,5 +1,5 @@
<?php <?php
require_once __DIR__ . "/../../config/bootstrap.php"; require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php'; require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin(); AdminAuth::requireLogin();
@@ -9,7 +9,7 @@ if (empty($_SESSION["csrf_token"])) {
$pageTitle = "Ajouter un TFE"; $pageTitle = "Ajouter un TFE";
require_once __DIR__ . '/../../src/ThesisCreateController.php'; require_once __DIR__ . '/../../src/Controllers/ThesisCreateController.php';
try { try {
$ctrl = ThesisCreateController::make(); $ctrl = ThesisCreateController::make();
@@ -57,7 +57,7 @@ include APP_ROOT . '/templates/header.php';
<h1>Ajouter un TFE</h1> <h1>Ajouter un TFE</h1>
</div> </div>
<?php include APP_ROOT . '/templates/partials/flash-messages.php'; ?>
<form action="actions/formulaire.php" method="post" enctype="multipart/form-data" class="admin-form"> <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"]) ?>"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION["csrf_token"]) ?>">

View File

@@ -1,5 +1,5 @@
<?php <?php
require_once __DIR__ . "/../../config/bootstrap.php"; require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php'; require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin(); AdminAuth::requireLogin();

View File

@@ -1,5 +1,5 @@
<?php <?php
require_once __DIR__ . "/../../config/bootstrap.php"; require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php'; require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin(); AdminAuth::requireLogin();
require_once __DIR__ . '/../../src/Database.php'; require_once __DIR__ . '/../../src/Database.php';
@@ -21,7 +21,7 @@ try {
<main id="main-content"> <main id="main-content">
<h1>Contenus</h1> <h1>Contenus</h1>
<?php include APP_ROOT . '/templates/partials/flash-messages.php'; ?>
<h2>Pages statiques</h2> <h2>Pages statiques</h2>

View File

@@ -1,6 +1,6 @@
<?php <?php
// Bootstrap application // Bootstrap application
require_once __DIR__ . "/../../config/bootstrap.php"; require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php'; require_once __DIR__ . '/../../src/AdminAuth.php';
// PHP-level auth guard (defence-in-depth behind nginx Basic Auth) // PHP-level auth guard (defence-in-depth behind nginx Basic Auth)
@@ -11,7 +11,7 @@ if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
} }
require_once APP_ROOT . '/src/ThesisEditController.php'; require_once APP_ROOT . '/src/Controllers/ThesisEditController.php';
$thesisId = isset($_GET['id']) ? intval($_GET['id']) : 0; $thesisId = isset($_GET['id']) ? intval($_GET['id']) : 0;
@@ -38,7 +38,7 @@ try {
<main id="main-content"> <main id="main-content">
<h1>Modifier un TFE</h1> <h1>Modifier un TFE</h1>
<?php include APP_ROOT . '/templates/partials/flash-messages.php'; ?>
<form method="post" action="/admin/actions/edit.php" class="admin-form" enctype="multipart/form-data"> <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="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">

View File

@@ -1,5 +1,5 @@
<?php <?php
require_once __DIR__ . "/../../config/bootstrap.php"; require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php'; require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin(); AdminAuth::requireLogin();
@@ -299,8 +299,6 @@ document.addEventListener('DOMContentLoaded', () => {
</script> </script>
<main id="main-content"> <main id="main-content">
<?php include APP_ROOT . '/templates/partials/flash-messages.php'; ?>
<!-- Title + filters + stats + import all in one toolbar row --> <!-- Title + filters + stats + import all in one toolbar row -->
<div class="admin-list-toolbar"> <div class="admin-list-toolbar">
<h1>Liste des TFE</h1> <h1>Liste des TFE</h1>
@@ -357,6 +355,9 @@ document.addEventListener('DOMContentLoaded', () => {
onclick="document.getElementById('import-dialog').showModal()"> onclick="document.getElementById('import-dialog').showModal()">
Importer un CSV Importer un CSV
</button> </button>
<a href="/admin/actions/export-csv.php" class="admin-btn admin-btn--sm">
Exporter CSV
</a>
</div> </div>
</div> </div>
@@ -503,7 +504,7 @@ document.addEventListener('DOMContentLoaded', () => {
<?php if ($importMessage || !empty($importErrors)): ?> <?php if ($importMessage || !empty($importErrors)): ?>
<div class="admin-import-status-card"> <div class="admin-import-status-card">
<?php if (!empty($importErrors)): ?> <?php if (!empty($importErrors)): ?>
<div role="alert" class="admin-import-status-card__errors"> <div class="toast admin-import-status-card__errors" role="alert" data-type="error">
<strong> Erreurs :</strong> <strong> Erreurs :</strong>
<ul class="admin-error-list"> <ul class="admin-error-list">
<?php foreach ($importErrors as $err): ?> <?php foreach ($importErrors as $err): ?>
@@ -513,7 +514,7 @@ document.addEventListener('DOMContentLoaded', () => {
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if ($importMessage): ?> <?php if ($importMessage): ?>
<p role="status" class="admin-import-status-card__success"> <?= htmlspecialchars($importMessage) ?></p> <p class="toast admin-import-status-card__success" role="status" data-type="success"> <?= htmlspecialchars($importMessage) ?></p>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>

View File

@@ -1,8 +1,8 @@
<?php <?php
require_once __DIR__ . '/../../config/bootstrap.php'; require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php'; require_once __DIR__ . '/../../src/AdminAuth.php';
if (!defined('ADMIN_PASSWORD_HASH')) { if (!AdminAuth::hasPassword()) {
header('Location: /admin/'); header('Location: /admin/');
exit; exit;
} }
@@ -31,7 +31,7 @@ $pageTitle = 'Connexion';
<div class="admin-login-box"> <div class="admin-login-box">
<h2>Administration</h2> <h2>Administration</h2>
<?php if ($error): ?> <?php if ($error): ?>
<p role="alert" data-type="error"> <?= htmlspecialchars($error) ?></p> <p class="toast" role="alert" data-type="error"> <?= htmlspecialchars($error) ?></p>
<?php endif; ?> <?php endif; ?>
<form method="post" action="/admin/login.php" class="admin-form"> <form method="post" action="/admin/login.php" class="admin-form">
<div> <div>

View File

@@ -1,5 +1,5 @@
<?php <?php
require_once __DIR__ . '/../../config/bootstrap.php'; require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php'; require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::logout(); AdminAuth::logout();

View File

@@ -1,18 +1,20 @@
<?php <?php
require_once __DIR__ . "/../../config/bootstrap.php"; require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php'; require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin(); AdminAuth::requireLogin();
$pageTitle = "Paramètres"; $pageTitle = "Paramètres";
$credentialsFile = APP_ROOT . '/config/admin_credentials.php'; $hasPassword = AdminAuth::hasPassword();
$hasPassword = defined('ADMIN_PASSWORD_HASH');
$maintenanceOn = file_exists(APP_ROOT . '/storage/maintenance.flag'); $maintenanceOn = file_exists(APP_ROOT . '/storage/maintenance.flag');
require_once APP_ROOT . '/src/Database.php'; require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/SmtpRelay.php';
$db = new Database(); $db = new Database();
$siteSettings = $db->getAllSettings(); $siteSettings = $db->getAllSettings();
$stats = $db->getThesesStats(); $stats = $db->getThesesStats();
$smtpSettings = SmtpRelay::getSettings($db);
$smtpConfigured = SmtpRelay::isConfigured($db);
if (empty($_SESSION['csrf_token'])) { if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
@@ -24,7 +26,7 @@ if (empty($_SESSION['csrf_token'])) {
<main id="main-content"> <main id="main-content">
<h1>Paramètres</h1> <h1>Paramètres</h1>
<?php include APP_ROOT . '/templates/partials/flash-messages.php'; ?>
<!-- ══════════════════════════════════════════════════════════════ <!-- ══════════════════════════════════════════════════════════════
MAINTENANCE MAINTENANCE
@@ -57,6 +59,17 @@ if (empty($_SESSION['csrf_token'])) {
<?php endif; ?> <?php endif; ?>
</div> </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 --> <!-- Danger zone: delete all TFE now inside maintenance -->
<fieldset class="param-danger-zone"> <fieldset class="param-danger-zone">
<legend>Supprimer tous les TFE</legend> <legend>Supprimer tous les TFE</legend>
@@ -120,6 +133,88 @@ if (empty($_SESSION['csrf_token'])) {
</form> </form>
</section> </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 COMPTE ADMINISTRATEUR
══════════════════════════════════════════════════════════════ --> ══════════════════════════════════════════════════════════════ -->
@@ -132,18 +227,18 @@ if (empty($_SESSION['csrf_token'])) {
<dd><?php $badgeType = 'ok'; $badgeValue = $hasPassword; $badgeOkLabel = 'Active'; $badgeWarnLabel = 'Non configurée'; include APP_ROOT . '/templates/partials/status-badge.php'; ?></dd> <dd><?php $badgeType = 'ok'; $badgeValue = $hasPassword; $badgeOkLabel = 'Active'; $badgeWarnLabel = 'Non configurée'; include APP_ROOT . '/templates/partials/status-badge.php'; ?></dd>
</div> </div>
<div> <div>
<dt>Fichier de configuration</dt> <dt>Stockage du hash</dt>
<dd> <dd>
<code>config/admin_credentials.php</code> <code>site_settings (DB)</code>
<?php $badgeType = 'ok'; $badgeValue = file_exists($credentialsFile); $badgeOkLabel = 'Présent'; $badgeWarnLabel = 'Absent'; include APP_ROOT . '/templates/partials/status-badge.php'; ?> <?php $badgeType = 'ok'; $badgeValue = $hasPassword; $badgeOkLabel = 'Présent'; $badgeWarnLabel = 'Absent'; include APP_ROOT . '/templates/partials/status-badge.php'; ?>
</dd> </dd>
</div> </div>
</dl> </dl>
<?php if (!$hasPassword): ?> <?php if (!$hasPassword): ?>
<p class="param-note"> <p class="param-note">
Aucun mot de passe PHP configuré. Le formulaire ci-dessous créera Aucun mot de passe PHP configuré. Le formulaire ci-dessous stockera
<code>config/admin_credentials.php</code> avec un hash bcrypt. un hash bcrypt dans la base de données.
</p> </p>
<?php endif; ?> <?php endif; ?>
@@ -183,20 +278,40 @@ if (empty($_SESSION['csrf_token'])) {
<fieldset class="param-danger-zone"> <fieldset class="param-danger-zone">
<legend>Supprimer la configuration du mot de passe PHP</legend> <legend>Supprimer la configuration du mot de passe PHP</legend>
<p> <p>
Supprime <code>config/admin_credentials.php</code>. L'accès admin Supprime le hash de la base de données. L'accès admin
dépendra uniquement de l'authentification nginx Basic Auth si elle est configurée. dépendra uniquement de l'authentification nginx Basic Auth si elle est configurée.
</p> </p>
<form method="post" action="/admin/actions/account.php" <form method="post" action="/admin/actions/account.php"
onsubmit="return confirm('Supprimer le fichier de mot de passe PHP ? L\'accès admin ne sera protégé que par nginx Basic Auth.')"> onsubmit="return confirm('Supprimer le mot de passe 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="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="remove_credentials"> <input type="hidden" name="action" value="remove_credentials">
<input type="hidden" name="redirect" value="/admin/parametres.php"> <input type="hidden" name="redirect" value="/admin/parametres.php">
<input type="hidden" name="current_password_remove" value=""> <input type="hidden" name="current_password_remove" value="">
<button type="submit" class="param-btn-danger">Supprimer le fichier</button> <button type="submit" class="param-btn-danger">Supprimer le mot de passe</button>
</form> </form>
</fieldset> </fieldset>
<?php endif; ?> <?php endif; ?>
</section> </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> </main>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?> <?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

View File

@@ -9,11 +9,11 @@
* Response: text/html fragment (no <html>/<head>/<body> wrapper). * Response: text/html fragment (no <html>/<head>/<body> wrapper).
* On any auth failure or bad request: 403 / 400 with a plain-text body. * On any auth failure or bad request: 403 / 400 with a plain-text body.
*/ */
require_once __DIR__ . "/../../config/bootstrap.php"; require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php'; require_once __DIR__ . '/../../src/AdminAuth.php';
require_once APP_ROOT . '/src/Database.php'; require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/SystemCache.php'; require_once APP_ROOT . '/src/SystemCache.php';
require_once APP_ROOT . '/src/SystemController.php'; require_once APP_ROOT . '/src/Controllers/SystemController.php';
if (!AdminAuth::isAuthenticated()) { if (!AdminAuth::isAuthenticated()) {
http_response_code(403); http_response_code(403);

View File

@@ -1,9 +1,9 @@
<?php <?php
require_once __DIR__ . "/../../config/bootstrap.php"; require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php'; require_once __DIR__ . '/../../src/AdminAuth.php';
require_once APP_ROOT . '/src/Database.php'; require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/SystemCache.php'; require_once APP_ROOT . '/src/SystemCache.php';
require_once APP_ROOT . '/src/SystemController.php'; require_once APP_ROOT . '/src/Controllers/SystemController.php';
AdminAuth::requireLogin(); AdminAuth::requireLogin();
$pageTitle = "Système"; $pageTitle = "Système";

View File

@@ -1,5 +1,5 @@
<?php <?php
require_once __DIR__ . '/../../config/bootstrap.php'; require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php'; require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin(); AdminAuth::requireLogin();
@@ -26,7 +26,7 @@ try {
<main id="main-content"> <main id="main-content">
<h1>Mots-clés (<?= count($tags) ?>)</h1> <h1>Mots-clés (<?= count($tags) ?>)</h1>
<?php include APP_ROOT . '/templates/partials/flash-messages.php'; ?>
<table> <table>
<thead> <thead>

View File

@@ -1,6 +1,6 @@
<?php <?php
// Bootstrap application // Bootstrap application
require_once __DIR__ . "/../../config/bootstrap.php"; require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php'; require_once __DIR__ . '/../../src/AdminAuth.php';
// Configure error reporting // Configure error reporting
@@ -109,7 +109,7 @@ if ($studentMode) {
<h1>Récapitulatif TFE</h1> <h1>Récapitulatif TFE</h1>
<?php if ($error): ?> <?php if ($error): ?>
<p role="alert" data-type="error"> <?= htmlspecialchars($error) ?></p> <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> <p><a href="/admin/add.php" class="admin-btn-secondary">Retour au formulaire</a></p>
<?php elseif ($thesis): ?> <?php elseif ($thesis): ?>

View File

@@ -1,5 +1,5 @@
<?php <?php
require_once __DIR__ . '/../config/bootstrap.php'; require_once __DIR__ . '/../bootstrap.php';
require_once APP_ROOT . '/src/Database.php'; require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/Parsedown.php'; require_once APP_ROOT . '/src/Parsedown.php';

View File

@@ -298,28 +298,58 @@
filter: brightness(0.9); filter: brightness(0.9);
} }
/* ── Alert messages ─────────────────────────────────────────────────────── */ /* ── Toast messages (bottom-center floating) ─────────────────────────── */
[role="alert"], #toast-container {
[role="status"] { position: fixed;
padding: var(--space-xs) var(--space-s); bottom: var(--space-l);
border-radius: 3px; left: 50%;
font-size: var(--step--1); transform: translateX(-50%);
margin-bottom: var(--space-m); z-index: 10000;
border-left: 3px solid; display: flex;
flex-direction: column-reverse;
gap: var(--space-xs);
pointer-events: none;
max-width: calc(100vw - 2 * var(--space-l));
} }
[role="alert"][data-type="error"] { .toast {
padding: var(--space-xs) var(--space-s);
border-radius: 6px;
font-size: var(--step--1);
border-left: 3px solid;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
pointer-events: auto;
animation: toast-enter 0.35s ease-out forwards;
max-width: 480px;
backdrop-filter: blur(6px);
}
.toast[data-type="error"] {
background: var(--accent-muted); background: var(--accent-muted);
border-color: var(--error); border-color: var(--error);
color: var(--text-primary); color: var(--text-primary);
} }
[role="status"][data-type="success"] { .toast[data-type="success"] {
background: var(--success-muted-bg); background: var(--success-muted-bg);
border-color: var(--success); border-color: var(--success);
color: var(--text-primary); color: var(--text-primary);
} }
.toast-exit {
animation: toast-exit 0.3s ease-in forwards;
}
@keyframes toast-enter {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes toast-exit {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(20px); height: 0; padding: 0; margin: 0; overflow: hidden; }
}
/* ── Stats cards ────────────────────────────────────────────────────────── */ /* ── Stats cards ────────────────────────────────────────────────────────── */
.admin-stats { .admin-stats {
display: flex; display: flex;
@@ -1098,8 +1128,7 @@
.admin-dialog .admin-form, .admin-dialog .admin-form,
.admin-dialog .admin-import-results, .admin-dialog .admin-import-results,
.admin-dialog [role="alert"], .admin-dialog .toast,
.admin-dialog [role="status"],
.admin-dialog__alert { .admin-dialog__alert {
margin: 0; margin: 0;
padding: var(--space-m) var(--space-l); padding: var(--space-m) var(--space-l);
@@ -1372,6 +1401,115 @@
margin-top: var(--space-xs); margin-top: var(--space-xs);
} }
/* ── Export zone (maintenance section) ───────────────────────────────── */
.param-export-zone {
border: 1px solid var(--border-primary);
border-radius: 4px;
padding: var(--space-m);
margin-bottom: var(--space-m);
background: var(--bg-secondary);
}
.param-export-zone legend {
width: auto;
font-weight: 600;
font-size: var(--step-0);
padding: 0 var(--space-xs);
}
.param-export-zone p {
font-size: var(--step--1);
color: var(--text-secondary);
margin-bottom: var(--space-xs);
}
.param-btn-export {
padding: var(--space-3xs) var(--space-s);
background: var(--primary, #2563eb);
color: var(--accent-foreground, #fff);
border: none;
border-radius: 3px;
font-size: var(--step--1);
font-family: inherit;
cursor: pointer;
transition: filter 0.15s;
}
.param-btn-export:hover {
filter: brightness(0.9);
}
/* ── SMTP section ─────────────────────────────────────────────────────── */
.param-smtp-status {
display: flex;
align-items: center;
gap: var(--space-s);
font-size: var(--step--1);
margin: var(--space-xs) 0 var(--space-s);
color: var(--text-secondary);
}
.param-badge-ok {
color: var(--accent-green);
font-weight: 600;
}
.param-badge-warn {
color: var(--warning);
font-weight: 600;
}
.param-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--space-s);
}
.param-grid label {
display: flex;
flex-direction: column;
gap: var(--space-3xs);
font-size: var(--step--1);
color: var(--text-secondary);
}
.param-grid input[type="text"],
.param-grid input[type="number"],
.param-grid input[type="email"],
.param-grid select {
width: 100%;
background: transparent;
border: none;
border-bottom: 1px solid var(--border-primary);
font-size: var(--step--1);
font-family: inherit;
padding: var(--space-3xs) 0;
border-radius: 0;
transition: border-color 0.15s;
}
.param-grid input:focus,
.param-grid select:focus {
outline: none;
border-bottom-color: var(--accent-primary);
}
.param-grid select {
cursor: pointer;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23999' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0 center;
padding-right: 1.2rem;
-webkit-appearance: none;
appearance: none;
}
.param-fieldset-inline {
border: 1px solid var(--border-primary);
border-radius: 4px;
padding: var(--space-m);
background: var(--bg-secondary);
}
.param-fieldset-inline legend {
font-weight: 600;
font-size: var(--step--1);
color: var(--text-secondary);
padding: 0 var(--space-xs);
}
/* ── Settings page sections — legacy aliases (kept for any remaining use) ─ */ /* ── Settings page sections — legacy aliases (kept for any remaining use) ─ */
.admin-settings-section { .admin-settings-section {
border: 1px solid var(--border-primary); border: 1px solid var(--border-primary);

View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 114 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 849 B

After

Width:  |  Height:  |  Size: 849 B

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -1,7 +1,7 @@
<?php <?php
// Load configuration // Load configuration
require_once __DIR__ . '/../config/bootstrap.php'; require_once __DIR__ . '/../bootstrap.php';
require_once APP_ROOT . '/src/HomeController.php'; require_once APP_ROOT . '/src/Controllers/HomeController.php';
$controller = HomeController::create(); $controller = HomeController::create();
$vars = $controller->handle(); $vars = $controller->handle();

View File

@@ -1,5 +1,5 @@
<?php <?php
require_once __DIR__ . '/../config/bootstrap.php'; require_once __DIR__ . '/../bootstrap.php';
require_once APP_ROOT . '/src/Database.php'; require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/Parsedown.php'; require_once APP_ROOT . '/src/Parsedown.php';

View File

@@ -7,7 +7,7 @@
* /partage/<slug>/submit POST endpoint for form submissions via share link * /partage/<slug>/submit POST endpoint for form submissions via share link
* /partage/thanks.php?id=N Post-submission confirmation page * /partage/thanks.php?id=N Post-submission confirmation page
*/ */
require_once __DIR__ . '/../../config/bootstrap.php'; require_once __DIR__ . '/../../bootstrap.php';
// Parse the requested path from REQUEST_URI // Parse the requested path from REQUEST_URI
$requestUri = $_SERVER['REQUEST_URI'] ?? ''; $requestUri = $_SERVER['REQUEST_URI'] ?? '';
@@ -219,7 +219,7 @@ function requirePasswordGate(array $link, string $slug): void
function renderShareLinkForm(string $slug, array $link): void function renderShareLinkForm(string $slug, array $link): void
{ {
require_once APP_ROOT . '/src/ThesisCreateController.php'; require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php';
try { try {
$ctrl = ThesisCreateController::make(); $ctrl = ThesisCreateController::make();
@@ -541,7 +541,7 @@ function handleShareLinkSubmission(string $slug): void
exit; exit;
} }
require_once APP_ROOT . '/src/ThesisCreateController.php'; require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php';
try { try {
$ctrl = ThesisCreateController::make(); $ctrl = ThesisCreateController::make();

View File

@@ -3,7 +3,7 @@
* Thanks page for share-link submissions. * Thanks page for share-link submissions.
* Displays a centered confirmation with a link to create another thesis via the same link. * Displays a centered confirmation with a link to create another thesis via the same link.
*/ */
require_once __DIR__ . '/../../config/bootstrap.php'; require_once __DIR__ . '/../../bootstrap.php';
App::boot(); App::boot();

View File

@@ -1,6 +1,6 @@
<?php <?php
require_once __DIR__ . '/../config/bootstrap.php'; require_once __DIR__ . '/../bootstrap.php';
require_once APP_ROOT . '/src/SearchController.php'; require_once APP_ROOT . '/src/Controllers/SearchController.php';
// Build controller (performs rate-limit check; exits with HTTP 429 if exceeded) // Build controller (performs rate-limit check; exits with HTTP 429 if exceeded)
$ctrl = SearchController::create(); $ctrl = SearchController::create();

View File

@@ -1,6 +1,6 @@
<?php <?php
require_once __DIR__ . '/../config/bootstrap.php'; require_once __DIR__ . '/../bootstrap.php';
require_once APP_ROOT . '/src/SearchController.php'; require_once APP_ROOT . '/src/Controllers/SearchController.php';
// Build controller (performs rate-limit check; exits with HTTP 429 if exceeded) // Build controller (performs rate-limit check; exits with HTTP 429 if exceeded)
$ctrl = SearchController::create(); $ctrl = SearchController::create();

View File

@@ -1,6 +1,6 @@
<?php <?php
require_once __DIR__ . '/../config/bootstrap.php'; require_once __DIR__ . '/../bootstrap.php';
require_once APP_ROOT . '/src/TfeController.php'; require_once APP_ROOT . '/src/Controllers/TfeController.php';
// Build controller (loads thesis, enforces visibility, builds OG tags; redirects on 404) // Build controller (loads thesis, enforces visibility, builds OG tags; redirects on 404)
$ctrl = TfeController::create(); $ctrl = TfeController::create();

View File

@@ -11,14 +11,14 @@ $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
// Route /partage/<slug> and /partage/<slug>/<action> to the partage entry // Route /partage/<slug> and /partage/<slug>/<action> to the partage entry
if (preg_match('#^/partage(/.*)?$#', $uri)) { if (preg_match('#^/partage(/.*)?$#', $uri)) {
$_SERVER['SCRIPT_NAME'] = '/partage/index.php'; $_SERVER['SCRIPT_NAME'] = '/partage/index.php';
require __DIR__ . '/../public/partage/index.php'; require __DIR__ . '/public/partage/index.php';
return true; return true;
} }
// Route /tfe/<...> to tfe.php // Route /tfe/<...> to tfe.php
if (preg_match('#^/tfe(/.*)?$#', $uri)) { if (preg_match('#^/tfe(/.*)?$#', $uri)) {
$_SERVER['SCRIPT_NAME'] = '/tfe.php'; $_SERVER['SCRIPT_NAME'] = '/tfe.php';
require __DIR__ . '/../public/tfe.php'; require __DIR__ . '/public/tfe.php';
return true; return true;
} }

View File

@@ -6,15 +6,10 @@
* It protects against proxy misconfiguration, bypass, and local-dev * It protects against proxy misconfiguration, bypass, and local-dev
* scenarios where the reverse proxy may be absent. * scenarios where the reverse proxy may be absent.
* *
* Usage (top of every admin page): * The admin password hash is stored in the site_settings table
* require_once __DIR__ . '/../../lib/AdminAuth.php'; * (key = 'admin_password_hash').
* AdminAuth::requireLogin();
* *
* Credential setup (production): * If the hash is empty/missing the guard is a no-op (dev / cli-server).
* php -r "echo password_hash('your-password', PASSWORD_DEFAULT);"
* # Paste result into config/admin_credentials.php as ADMIN_PASSWORD_HASH
*
* If ADMIN_PASSWORD_HASH is not defined the guard is a no-op (dev / cli-server).
*/ */
class AdminAuth class AdminAuth
{ {
@@ -41,33 +36,47 @@ class AdminAuth
session_start(); 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. * Gate every admin page.
* *
* Authentication order: * Authentication order:
* 1. Session already authenticated pass through. * 1. No password hash configured dev mode, pass through.
* 2. nginx Basic Auth password present in $_SERVER['PHP_AUTH_PW'] * 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 * validate it with password_verify; on success create session
* (seamless: user only sees the browser Basic Auth dialog). * (seamless: user only sees the browser Basic Auth dialog).
* 3. Neither redirect to the PHP login form (fallback for when * 4. Neither redirect to the PHP login form.
* the reverse proxy is absent / misconfigured).
*
* No-op if ADMIN_PASSWORD_HASH is not defined (development / cli-server).
*/ */
public static function requireLogin(): void public static function requireLogin(): void
{ {
self::startSession(); self::startSession();
if (!defined('ADMIN_PASSWORD_HASH')) { $storedHash = self::getStoredHash();
// No password configured → development / cli-server mode, skip PHP auth. if ($storedHash === null) {
return; return; // No password configured → dev / cli-server, skip.
} }
if (!empty($_SESSION[self::SESSION_KEY])) { if (!empty($_SESSION[self::SESSION_KEY])) {
return; // already authenticated via session return; // Already authenticated via session.
} }
// Try to auto-authenticate from the nginx Basic Auth credentials. // Try to auto-authenticate from the nginx Basic Auth credentials.
// If nginx Basic Auth is bypassed, PHP_AUTH_PW won't be set and this if (isset($_SERVER['PHP_AUTH_PW']) && self::verifyHash($_SERVER['PHP_AUTH_PW'], $storedHash)) {
// branch is skipped — the fallback login form is shown instead.
if (isset($_SERVER['PHP_AUTH_PW']) && self::login($_SERVER['PHP_AUTH_PW'])) {
return; return;
} }
header('Location: ' . self::LOGIN_URL); header('Location: ' . self::LOGIN_URL);
@@ -75,15 +84,15 @@ class AdminAuth
} }
/** /**
* Validate a plaintext password against the stored bcrypt hash. * Validate a plaintext password against the stored hash.
* On success: regenerates the session ID and marks the session authenticated. * On success: regenerates the session ID and marks the session authenticated.
* *
* @return bool true on success, false on wrong password / no hash configured. * @return bool true on success, false on wrong password / no hash stored.
*/ */
public static function login(string $password): bool public static function login(string $password): bool
{ {
$hash = defined('ADMIN_PASSWORD_HASH') ? ADMIN_PASSWORD_HASH : null; $storedHash = self::getStoredHash();
if ($hash === null || !password_verify($password, $hash)) { if ($storedHash === null || !self::verifyHash($password, $storedHash)) {
return false; return false;
} }
self::startSession(); self::startSession();
@@ -93,19 +102,55 @@ class AdminAuth
return true; 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). * Check whether the current request is authenticated (without redirecting).
*/ */
public static function isAuthenticated(): bool public static function isAuthenticated(): bool
{ {
self::startSession(); self::startSession();
// No password configured → development mode, skip PHP auth. $storedHash = self::getStoredHash();
if (!defined('ADMIN_PASSWORD_HASH')) { if ($storedHash === null) {
return true; return true; // No password configured → dev mode.
} }
return !empty($_SESSION[self::SESSION_KEY]); 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). * Destroy the session (logout).
*/ */

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,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

@@ -1,6 +1,6 @@
<?php <?php
require_once __DIR__ . '/config.php'; require_once __DIR__ . '/../config/config.php';
/** /**
* Unified Database connection class for Post-ERG thesis database * Unified Database connection class for Post-ERG thesis database
@@ -1682,6 +1682,99 @@ class Database {
return $this->pdo->lastInsertId(); return $this->pdo->lastInsertId();
} }
// ========================================================================
// EXPORT HELPERS — used by ExportController
// ========================================================================
/**
* Fetch all theses (admin includes unpublished) with every column
* needed for the CSV export.
*/
public function getAllThesesForExport(): array {
return $this->pdo->query("
SELECT
t.id, t.identifier, t.title, t.subtitle, t.year,
o.name AS orientation,
ap.name AS ap_program,
ft.name AS finality_type,
at.name AS access_type,
lt.name AS license_name,
t.synopsis,
t.context_note,
t.remarks,
t.file_size_info,
t.jury_points,
t.baiu_link
FROM theses t
LEFT JOIN orientations o ON t.orientation_id = o.id
LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id
LEFT JOIN finality_types ft ON t.finality_id = ft.id
LEFT JOIN access_types at ON t.access_type_id = at.id
LEFT JOIN license_types lt ON t.license_id = lt.id
ORDER BY t.year DESC, t.title ASC
")->fetchAll();
}
/**
* All thesis→author rows with author name and email.
*/
public function getAllThesisAuthorsForExport(): array {
return $this->pdo->query("
SELECT ta.thesis_id, a.name, a.email
FROM thesis_authors ta
JOIN authors a ON a.id = ta.author_id
ORDER BY ta.thesis_id, ta.author_order
")->fetchAll();
}
/**
* All thesis→supervisor rows with name.
*/
public function getAllThesisSupervisorsForExport(): array {
return $this->pdo->query("
SELECT ts.thesis_id, s.name
FROM thesis_supervisors ts
JOIN supervisors s ON s.id = ts.supervisor_id
ORDER BY ts.thesis_id, ts.supervisor_order
")->fetchAll();
}
/**
* All thesis→tag rows with tag name.
*/
public function getAllThesisTagsForExport(): array {
return $this->pdo->query("
SELECT tt.thesis_id, t.name
FROM thesis_tags tt
JOIN tags t ON t.id = tt.tag_id
ORDER BY tt.thesis_id, t.name
")->fetchAll();
}
/**
* All thesis→language rows with language name.
*/
public function getAllThesisLanguagesForExport(): array {
return $this->pdo->query("
SELECT tl.thesis_id, l.name
FROM thesis_languages tl
JOIN languages l ON l.id = tl.language_id
ORDER BY tl.thesis_id, l.name
")->fetchAll();
}
/**
* All thesis→format rows with format name.
*/
public function getAllThesisFormatsForExport(): array {
return $this->pdo->query("
SELECT tf.thesis_id, ft.name
FROM thesis_formats tf
JOIN format_types ft ON ft.id = tf.format_id
ORDER BY tf.thesis_id, ft.name
")->fetchAll();
}
// ======================================================================== // ========================================================================
// SINGLETON PATTERN ENFORCEMENT // SINGLETON PATTERN ENFORCEMENT
// ======================================================================== // ========================================================================

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;
}
}

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);
}
}

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