feat: extract MediaController, wire into Dispatcher, delete media.php
22
DEV.md
@@ -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
|
||||
|
||||
|
||||
58
README.md
@@ -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
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
@@ -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; ?>
|
||||
@@ -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';
|
||||
|
||||
@@ -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::logout();
|
||||
header('Location: /admin/login.php');
|
||||
exit;
|
||||
} else {
|
||||
App::flash('error', 'Impossible de supprimer le fichier de configuration.');
|
||||
header('Location: ' . $backUrl);
|
||||
exit;
|
||||
}
|
||||
AdminAuth::removePasswordHash();
|
||||
// Destroy session so the user is forced to re-authenticate.
|
||||
AdminAuth::logout();
|
||||
header('Location: /admin/login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Change / set password ─────────────────────────────────────────────────────
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../../config/bootstrap.php';
|
||||
require_once __DIR__ . '/../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||
|
||||
AdminAuth::requireLogin();
|
||||
@@ -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();
|
||||
36
app/public/admin/actions/export-csv.php
Normal 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;
|
||||
29
app/public/admin/actions/export-db.php
Normal 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;
|
||||
@@ -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();
|
||||
@@ -1,5 +1,5 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../../config/bootstrap.php';
|
||||
require_once __DIR__ . '/../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||
AdminAuth::requireLogin();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../../config/bootstrap.php';
|
||||
require_once __DIR__ . '/../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||
|
||||
AdminAuth::requireLogin();
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../../config/bootstrap.php';
|
||||
require_once __DIR__ . '/../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||
AdminAuth::requireLogin();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../../config/bootstrap.php';
|
||||
require_once __DIR__ . '/../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||
AdminAuth::requireLogin();
|
||||
|
||||
@@ -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"]) ?>">
|
||||
@@ -1,5 +1,5 @@
|
||||
<?php
|
||||
require_once __DIR__ . "/../../config/bootstrap.php";
|
||||
require_once __DIR__ . "/../../bootstrap.php";
|
||||
require_once __DIR__ . '/../../src/AdminAuth.php';
|
||||
AdminAuth::requireLogin();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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']) ?>">
|
||||
@@ -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; ?>
|
||||
@@ -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>
|
||||
@@ -1,5 +1,5 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../config/bootstrap.php';
|
||||
require_once __DIR__ . '/../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../src/AdminAuth.php';
|
||||
|
||||
AdminAuth::logout();
|
||||
@@ -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()">✕</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'; ?>
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
@@ -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): ?>
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 849 B After Width: | Height: | Size: 849 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
@@ -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();
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
43
app/src/Controllers/AboutController.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
166
app/src/Controllers/ExportController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
36
app/src/Controllers/LicenceController.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
57
app/src/Controllers/LiveReloadController.php
Normal 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];
|
||||
}
|
||||
}
|
||||
114
app/src/Controllers/MediaController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||