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).
@@ -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`)
- 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
```bash
@@ -95,3 +64,24 @@ ssh posterg "sudo bash /tmp/manage-admin-users.sh"
- Uploads stored outside webroot, served via controlled `media.php`
- Rate limiting on public search (`src/RateLimit.php`)
- 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
- [x] Create migration 010_apropos_contents.sql (apropos_contents table, seed defaults)
- [x] Add apropos CRUD methods to Database.php
- [x] Create admin/contenus.php (replaces pages.php)
- [x] Create admin/contenus-edit.php (edit pages + apropos contacts/credits/erg_url)
- [x] Create admin/actions/apropos.php - save handler for apropos contents
- [x] Update templates/header.php: rename "Pages statiques" → "Contenus", update nav links
- [x] Update public/apropos.php: read contacts/credits/erg_url from DB instead of config
- [x] Delete config/apropos.php
- [x] Delete public/admin/pages.php
- [x] Delete public/admin/pages-edit.php
- [x] Delete public/admin/actions/page.php
- [x] Update storage/schema.sql with apropos_contents table + trigger
- [x] Rework system.php/system.js: replace custom fetch() JS with HTMX, inline onclick for copy + collapse
- [x] Replace inline alert CSS in admin.css with floating bottom-center toast styles (fixed, z-index, animation)
- [x] Update flash-messages.php partial to output `.toast` markup in hidden container for footer JS
- [x] Add toast container HTML + JS to admin footer.php (centralised, 4s auto-dismiss with fade-out)
- [x] Remove redundant flash-messages.php includes from all admin pages (8 files)
- [x] Convert hardcoded alerts in login.php, thanks.php, index.php import to `.toast` class
- [x] Update admin.css dialog rule from `[role=alert/status]` to `.toast`
- [x] Commit with jj
- [x] Move DB export from admin/index.php to admin/parametres.php (maintenance section)
- [x] Reorganize src/ - move 7 controllers to src/Controllers/
- [x] Create Controllers directory
- [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('APP_ROOT', dirname(__DIR__));
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 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');
// Error reporting
@@ -24,10 +24,8 @@ if (php_sapi_name() === 'cli-server') {
ini_set('log_errors', '1');
}
// Load admin credentials if available (defines ADMIN_PASSWORD_HASH for AdminAuth)
if (file_exists(APP_ROOT . '/config/admin_credentials.php')) {
require_once APP_ROOT . '/config/admin_credentials.php';
}
// 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';

View File

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

View File

@@ -1,12 +1,11 @@
<?php
require_once __DIR__ . "/../../config/bootstrap.php";
require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
$pageTitle = "Compte administrateur";
$credentialsFile = APP_ROOT . '/config/admin_credentials.php';
$hasPassword = defined('ADMIN_PASSWORD_HASH');
$hasPassword = AdminAuth::hasPassword();
// Flash messages are consumed by the flash-messages partial below.
@@ -20,7 +19,7 @@ if (empty($_SESSION['csrf_token'])) {
<main id="main-content">
<h1>Compte administrateur</h1>
<?php include APP_ROOT . '/templates/partials/flash-messages.php'; ?>
<!-- Status info -->
<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>
</div>
<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>
<code class="admin-account-status__code">config/admin_credentials.php</code>
<?php $badgeType = 'ok'; $badgeValue = file_exists($credentialsFile); $badgeOkLabel = 'Présent'; $badgeWarnLabel = 'Absent'; include APP_ROOT . '/templates/partials/status-badge.php'; ?>
<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 créera
<code>config/admin_credentials.php</code> avec un hash bcrypt.
Aucun mot de passe PHP configuré. Le formulaire ci-dessous stockera
un hash bcrypt dans la base de données.
</p>
<?php endif; ?>
</dl>
@@ -91,16 +90,16 @@ if (empty($_SESSION['csrf_token'])) {
<p class="admin-danger-zone__description">
<strong>Supprimer la configuration du mot de passe PHP</strong><br>
<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.
</small>
</p>
<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="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 le fichier</button>
<button type="submit" class="admin-btn admin-btn--danger">Supprimer</button>
</form>
</div>
<?php endif; ?>

View File

@@ -2,7 +2,7 @@
/**
* 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/ShareLink.php';

View File

@@ -1,14 +1,16 @@
<?php
/**
* Admin account action update or remove admin_credentials.php
* Admin account action update or remove admin password.
*
* Actions:
* 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/Database.php';
AdminAuth::requireLogin();
// ── CSRF ──────────────────────────────────────────────────────────────────────
@@ -17,37 +19,25 @@ if (empty($_SESSION['csrf_token']) || ($_POST['csrf_token'] ?? '') !== $_SESSION
die('Invalid CSRF token.');
}
$credentialsFile = APP_ROOT . '/config/admin_credentials.php';
$hasPassword = defined('ADMIN_PASSWORD_HASH');
$hasPassword = AdminAuth::hasPassword();
$action = $_POST['action'] ?? 'change_password';
// ── Remove credentials ────────────────────────────────────────────────────────
// ── 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 fichier de mot de passe à supprimer.');
App::flash('error', 'Aucun mot de passe à supprimer.');
header('Location: ' . $backUrl);
exit;
}
if (!is_writable($credentialsFile) && !is_writable(dirname($credentialsFile))) {
App::flash('error', 'Le fichier de configuration n\'est pas accessible en écriture.');
header('Location: ' . $backUrl);
exit;
}
if (@unlink($credentialsFile)) {
// Destroy session so the user is forced to re-authenticate via nginx Basic Auth.
AdminAuth::removePasswordHash();
// Destroy session so the user is forced to re-authenticate.
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 ─────────────────────────────────────────────────────
@@ -89,32 +79,8 @@ if ($hash === false) {
exit;
}
// 4. Write credentials file.
$configContent = '<?php' . "\n"
. '/**' . "\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;
}
// 4. Store hash in DB.
AdminAuth::setPasswordHash($hash);
// 5. Regenerate session (password changed — invalidate old sessions).
session_regenerate_id(true);

View File

@@ -3,7 +3,7 @@
* Save handler for apropos contents (contacts, credits).
* 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';
AdminAuth::requireLogin();

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<?php
require_once __DIR__ . '/../../../config/bootstrap.php';
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
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/SmtpRelay.php';
$db = new Database();
$section = $_POST['section'] ?? '';
@@ -23,6 +24,22 @@ if ($section === 'formulaire') {
$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.");
}

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<?php
require_once __DIR__ . "/../../config/bootstrap.php";
require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
@@ -9,7 +9,7 @@ if (empty($_SESSION["csrf_token"])) {
$pageTitle = "Ajouter un TFE";
require_once __DIR__ . '/../../src/ThesisCreateController.php';
require_once __DIR__ . '/../../src/Controllers/ThesisCreateController.php';
try {
$ctrl = ThesisCreateController::make();
@@ -57,7 +57,7 @@ include APP_ROOT . '/templates/header.php';
<h1>Ajouter un TFE</h1>
</div>
<?php include APP_ROOT . '/templates/partials/flash-messages.php'; ?>
<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"]) ?>">

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<?php
// Bootstrap application
require_once __DIR__ . "/../../config/bootstrap.php";
require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
// 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));
}
require_once APP_ROOT . '/src/ThesisEditController.php';
require_once APP_ROOT . '/src/Controllers/ThesisEditController.php';
$thesisId = isset($_GET['id']) ? intval($_GET['id']) : 0;
@@ -38,7 +38,7 @@ try {
<main id="main-content">
<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">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">

View File

@@ -1,5 +1,5 @@
<?php
require_once __DIR__ . "/../../config/bootstrap.php";
require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
@@ -299,8 +299,6 @@ document.addEventListener('DOMContentLoaded', () => {
</script>
<main id="main-content">
<?php include APP_ROOT . '/templates/partials/flash-messages.php'; ?>
<!-- Title + filters + stats + import all in one toolbar row -->
<div class="admin-list-toolbar">
<h1>Liste des TFE</h1>
@@ -357,6 +355,9 @@ document.addEventListener('DOMContentLoaded', () => {
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>
@@ -503,7 +504,7 @@ document.addEventListener('DOMContentLoaded', () => {
<?php if ($importMessage || !empty($importErrors)): ?>
<div class="admin-import-status-card">
<?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>
<ul class="admin-error-list">
<?php foreach ($importErrors as $err): ?>
@@ -513,7 +514,7 @@ document.addEventListener('DOMContentLoaded', () => {
</div>
<?php endif; ?>
<?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; ?>
</div>
<?php endif; ?>

View File

@@ -1,8 +1,8 @@
<?php
require_once __DIR__ . '/../../config/bootstrap.php';
require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php';
if (!defined('ADMIN_PASSWORD_HASH')) {
if (!AdminAuth::hasPassword()) {
header('Location: /admin/');
exit;
}
@@ -31,7 +31,7 @@ $pageTitle = 'Connexion';
<div class="admin-login-box">
<h2>Administration</h2>
<?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; ?>
<form method="post" action="/admin/login.php" class="admin-form">
<div>

View File

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

View File

@@ -1,18 +1,20 @@
<?php
require_once __DIR__ . "/../../config/bootstrap.php";
require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
$pageTitle = "Paramètres";
$credentialsFile = APP_ROOT . '/config/admin_credentials.php';
$hasPassword = defined('ADMIN_PASSWORD_HASH');
$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));
@@ -24,7 +26,7 @@ if (empty($_SESSION['csrf_token'])) {
<main id="main-content">
<h1>Paramètres</h1>
<?php include APP_ROOT . '/templates/partials/flash-messages.php'; ?>
<!-- ══════════════════════════════════════════════════════════════
MAINTENANCE
@@ -57,6 +59,17 @@ if (empty($_SESSION['csrf_token'])) {
<?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>
@@ -120,6 +133,88 @@ if (empty($_SESSION['csrf_token'])) {
</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
══════════════════════════════════════════════════════════════ -->
@@ -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>
</div>
<div>
<dt>Fichier de configuration</dt>
<dt>Stockage du hash</dt>
<dd>
<code>config/admin_credentials.php</code>
<?php $badgeType = 'ok'; $badgeValue = file_exists($credentialsFile); $badgeOkLabel = 'Présent'; $badgeWarnLabel = 'Absent'; include APP_ROOT . '/templates/partials/status-badge.php'; ?>
<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 créera
<code>config/admin_credentials.php</code> avec un hash bcrypt.
Aucun mot de passe PHP configuré. Le formulaire ci-dessous stockera
un hash bcrypt dans la base de données.
</p>
<?php endif; ?>
@@ -183,20 +278,40 @@ if (empty($_SESSION['csrf_token'])) {
<fieldset class="param-danger-zone">
<legend>Supprimer la configuration du mot de passe PHP</legend>
<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.
</p>
<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="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 fichier</button>
<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

@@ -9,11 +9,11 @@
* 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__ . "/../../config/bootstrap.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/SystemController.php';
require_once APP_ROOT . '/src/Controllers/SystemController.php';
if (!AdminAuth::isAuthenticated()) {
http_response_code(403);

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<?php
// Bootstrap application
require_once __DIR__ . "/../../config/bootstrap.php";
require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
// Configure error reporting
@@ -109,7 +109,7 @@ if ($studentMode) {
<h1>Récapitulatif TFE</h1>
<?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>
<?php elseif ($thesis): ?>

View File

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

View File

@@ -298,28 +298,58 @@
filter: brightness(0.9);
}
/* ── Alert messages ─────────────────────────────────────────────────────── */
[role="alert"],
[role="status"] {
padding: var(--space-xs) var(--space-s);
border-radius: 3px;
font-size: var(--step--1);
margin-bottom: var(--space-m);
border-left: 3px solid;
/* ── Toast messages (bottom-center floating) ─────────────────────────── */
#toast-container {
position: fixed;
bottom: var(--space-l);
left: 50%;
transform: translateX(-50%);
z-index: 10000;
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);
border-color: var(--error);
color: var(--text-primary);
}
[role="status"][data-type="success"] {
.toast[data-type="success"] {
background: var(--success-muted-bg);
border-color: var(--success);
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 ────────────────────────────────────────────────────────── */
.admin-stats {
display: flex;
@@ -1098,8 +1128,7 @@
.admin-dialog .admin-form,
.admin-dialog .admin-import-results,
.admin-dialog [role="alert"],
.admin-dialog [role="status"],
.admin-dialog .toast,
.admin-dialog__alert {
margin: 0;
padding: var(--space-m) var(--space-l);
@@ -1372,6 +1401,115 @@
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) ─ */
.admin-settings-section {
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
// Load configuration
require_once __DIR__ . '/../config/bootstrap.php';
require_once APP_ROOT . '/src/HomeController.php';
require_once __DIR__ . '/../bootstrap.php';
require_once APP_ROOT . '/src/Controllers/HomeController.php';
$controller = HomeController::create();
$vars = $controller->handle();

View File

@@ -1,5 +1,5 @@
<?php
require_once __DIR__ . '/../config/bootstrap.php';
require_once __DIR__ . '/../bootstrap.php';
require_once APP_ROOT . '/src/Database.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/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
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
@@ -219,7 +219,7 @@ function requirePasswordGate(array $link, string $slug): void
function renderShareLinkForm(string $slug, array $link): void
{
require_once APP_ROOT . '/src/ThesisCreateController.php';
require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php';
try {
$ctrl = ThesisCreateController::make();
@@ -541,7 +541,7 @@ function handleShareLinkSubmission(string $slug): void
exit;
}
require_once APP_ROOT . '/src/ThesisCreateController.php';
require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php';
try {
$ctrl = ThesisCreateController::make();

View File

@@ -3,7 +3,7 @@
* Thanks page for share-link submissions.
* 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();

View File

@@ -1,6 +1,6 @@
<?php
require_once __DIR__ . '/../config/bootstrap.php';
require_once APP_ROOT . '/src/SearchController.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();

View File

@@ -1,6 +1,6 @@
<?php
require_once __DIR__ . '/../config/bootstrap.php';
require_once APP_ROOT . '/src/SearchController.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();

View File

@@ -1,6 +1,6 @@
<?php
require_once __DIR__ . '/../config/bootstrap.php';
require_once APP_ROOT . '/src/TfeController.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();

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
if (preg_match('#^/partage(/.*)?$#', $uri)) {
$_SERVER['SCRIPT_NAME'] = '/partage/index.php';
require __DIR__ . '/../public/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';
require __DIR__ . '/public/tfe.php';
return true;
}

View File

@@ -6,15 +6,10 @@
* It protects against proxy misconfiguration, bypass, and local-dev
* scenarios where the reverse proxy may be absent.
*
* Usage (top of every admin page):
* require_once __DIR__ . '/../../lib/AdminAuth.php';
* AdminAuth::requireLogin();
* The admin password hash is stored in the site_settings table
* (key = 'admin_password_hash').
*
* Credential setup (production):
* 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).
* If the hash is empty/missing the guard is a no-op (dev / cli-server).
*/
class AdminAuth
{
@@ -41,33 +36,47 @@ class AdminAuth
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. Session already authenticated pass through.
* 2. nginx Basic Auth password present in $_SERVER['PHP_AUTH_PW']
* 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).
* 3. Neither redirect to the PHP login form (fallback for when
* the reverse proxy is absent / misconfigured).
*
* No-op if ADMIN_PASSWORD_HASH is not defined (development / cli-server).
* 4. Neither redirect to the PHP login form.
*/
public static function requireLogin(): void
{
self::startSession();
if (!defined('ADMIN_PASSWORD_HASH')) {
// No password configured → development / cli-server mode, skip PHP auth.
return;
$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
return; // Already authenticated via session.
}
// 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
// branch is skipped — the fallback login form is shown instead.
if (isset($_SERVER['PHP_AUTH_PW']) && self::login($_SERVER['PHP_AUTH_PW'])) {
if (isset($_SERVER['PHP_AUTH_PW']) && self::verifyHash($_SERVER['PHP_AUTH_PW'], $storedHash)) {
return;
}
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.
*
* @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
{
$hash = defined('ADMIN_PASSWORD_HASH') ? ADMIN_PASSWORD_HASH : null;
if ($hash === null || !password_verify($password, $hash)) {
$storedHash = self::getStoredHash();
if ($storedHash === null || !self::verifyHash($password, $storedHash)) {
return false;
}
self::startSession();
@@ -93,19 +102,55 @@ class AdminAuth
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();
// No password configured → development mode, skip PHP auth.
if (!defined('ADMIN_PASSWORD_HASH')) {
return true;
$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).
*/

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
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/../config/config.php';
/**
* Unified Database connection class for Post-ERG thesis database
@@ -1682,6 +1682,99 @@ class Database {
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
// ========================================================================

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