diff --git a/DEV.md b/DEV.md deleted file mode 100644 index 4ea08ff..0000000 --- a/DEV.md +++ /dev/null @@ -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 - - diff --git a/README.md b/README.md index b092d75..eecf3a5 100644 --- a/README.md +++ b/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 diff --git a/TODO.md b/TODO.md index c3ac07f..6d5c080 100644 --- a/TODO.md +++ b/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) diff --git a/config/bootstrap.php b/app/bootstrap.php similarity index 79% rename from config/bootstrap.php rename to app/bootstrap.php index 09444bd..4211882 100644 --- a/config/bootstrap.php +++ b/app/bootstrap.php @@ -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'; diff --git a/public/admin/.htaccess b/app/public/admin/.htaccess similarity index 100% rename from public/admin/.htaccess rename to app/public/admin/.htaccess diff --git a/public/admin/README.md b/app/public/admin/README.md similarity index 100% rename from public/admin/README.md rename to app/public/admin/README.md diff --git a/public/admin/acces-etudiante.php b/app/public/admin/acces-etudiante.php similarity index 98% rename from public/admin/acces-etudiante.php rename to app/public/admin/acces-etudiante.php index af4c91a..d26eb81 100644 --- a/public/admin/acces-etudiante.php +++ b/app/public/admin/acces-etudiante.php @@ -1,5 +1,5 @@
- +

Accès étudiant·e

diff --git a/public/admin/account.php b/app/public/admin/account.php similarity index 79% rename from public/admin/account.php rename to app/public/admin/account.php index efc3e7d..4d27531 100644 --- a/public/admin/account.php +++ b/app/public/admin/account.php @@ -1,12 +1,11 @@

Compte administrateur

- +
- +
- - + +

- Aucun mot de passe PHP configuré. Le formulaire ci-dessous créera - config/admin_credentials.php avec un hash bcrypt. + Aucun mot de passe PHP configuré. Le formulaire ci-dessous stockera + un hash bcrypt dans la base de données.

@@ -91,16 +90,16 @@ if (empty($_SESSION['csrf_token'])) {

Supprimer la configuration du mot de passe PHP
- Supprime config/admin_credentials.php. 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.

+ onsubmit="return confirm('Supprimer le mot de passe PHP ? L\'accès admin ne sera protégé que par nginx Basic Auth.')"> - +
diff --git a/public/admin/actions/acces-etudiante.php b/app/public/admin/actions/acces-etudiante.php similarity index 97% rename from public/admin/actions/acces-etudiante.php rename to app/public/admin/actions/acces-etudiante.php index 0323ff3..498e3d4 100644 --- a/public/admin/actions/acces-etudiante.php +++ b/app/public/admin/actions/acces-etudiante.php @@ -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'; diff --git a/public/admin/actions/account.php b/app/public/admin/actions/account.php similarity index 54% rename from public/admin/actions/account.php rename to app/public/admin/actions/account.php index 628bcf4..87f8814 100644 --- a/public/admin/actions/account.php +++ b/app/public/admin/actions/account.php @@ -1,14 +1,16 @@ 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); diff --git a/public/admin/actions/apropos.php b/app/public/admin/actions/apropos.php similarity index 97% rename from public/admin/actions/apropos.php rename to app/public/admin/actions/apropos.php index 11623db..10ba380 100644 --- a/public/admin/actions/apropos.php +++ b/app/public/admin/actions/apropos.php @@ -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(); diff --git a/public/admin/actions/delete.php b/app/public/admin/actions/delete.php similarity index 96% rename from public/admin/actions/delete.php rename to app/public/admin/actions/delete.php index 2c8c237..eda2a87 100644 --- a/public/admin/actions/delete.php +++ b/app/public/admin/actions/delete.php @@ -1,5 +1,5 @@ exportAllTheses(); +foreach ($rows as $csvLine) { + fputcsv($out, $csvLine, ',', '"', ''); +} + +fclose($out); +exit; diff --git a/app/public/admin/actions/export-db.php b/app/public/admin/actions/export-db.php new file mode 100644 index 0000000..50b6fee --- /dev/null +++ b/app/public/admin/actions/export-db.php @@ -0,0 +1,29 @@ +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; diff --git a/public/admin/actions/formulaire.php b/app/public/admin/actions/formulaire.php similarity index 90% rename from public/admin/actions/formulaire.php rename to app/public/admin/actions/formulaire.php index 9b77247..954fca6 100644 --- a/public/admin/actions/formulaire.php +++ b/app/public/admin/actions/formulaire.php @@ -1,6 +1,6 @@ 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."); } diff --git a/public/admin/actions/tag.php b/app/public/admin/actions/tag.php similarity index 96% rename from public/admin/actions/tag.php rename to app/public/admin/actions/tag.php index 8f34bef..66a752f 100644 --- a/public/admin/actions/tag.php +++ b/app/public/admin/actions/tag.php @@ -1,5 +1,5 @@ Ajouter un TFE - +
"> diff --git a/public/admin/contenus-edit.php b/app/public/admin/contenus-edit.php similarity index 99% rename from public/admin/contenus-edit.php rename to app/public/admin/contenus-edit.php index 3a3ece3..2f8a351 100644 --- a/public/admin/contenus-edit.php +++ b/app/public/admin/contenus-edit.php @@ -1,5 +1,5 @@

Contenus

- +

Pages statiques

diff --git a/public/admin/edit.php b/app/public/admin/edit.php similarity index 97% rename from public/admin/edit.php rename to app/public/admin/edit.php index 7772418..ed2919a 100644 --- a/public/admin/edit.php +++ b/app/public/admin/edit.php @@ -1,6 +1,6 @@

Modifier un TFE

- + diff --git a/public/admin/import.php b/app/public/admin/import.php similarity index 100% rename from public/admin/import.php rename to app/public/admin/import.php diff --git a/public/admin/index.php b/app/public/admin/index.php similarity index 98% rename from public/admin/index.php rename to app/public/admin/index.php index f5d13a7..aa986a1 100644 --- a/public/admin/index.php +++ b/app/public/admin/index.php @@ -1,5 +1,5 @@ {
- -

Liste des TFE

@@ -357,6 +355,9 @@ document.addEventListener('DOMContentLoaded', () => { onclick="document.getElementById('import-dialog').showModal()"> Importer un CSV + + Exporter CSV +
@@ -503,7 +504,7 @@ document.addEventListener('DOMContentLoaded', () => {
- diff --git a/public/admin/login.php b/app/public/admin/login.php similarity index 88% rename from public/admin/login.php rename to app/public/admin/login.php index af84d52..9e45bbe 100644 --- a/public/admin/login.php +++ b/app/public/admin/login.php @@ -1,8 +1,8 @@

Administration

-

+
diff --git a/public/admin/logout.php b/app/public/admin/logout.php similarity index 69% rename from public/admin/logout.php rename to app/public/admin/logout.php index 001e0ab..5b3f944 100644 --- a/public/admin/logout.php +++ b/app/public/admin/logout.php @@ -1,5 +1,5 @@ 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'])) {

Paramètres

- + +
+ Exporter la base de données +

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.

+ +
+
Supprimer tous les TFE @@ -120,6 +133,88 @@ if (empty($_SESSION['csrf_token'])) { + +
+

Relay SMTP

+

+ Identifiants du serveur SMTP utilisé pour l'envoi d'e-mails + (notifications, partage de TFE, etc.). +

+
+ + ✓ Configuré + : () + + ✗ Non configuré + +
+ +
+ + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ Expéditeur par défaut +
+
+ + +
+
+ + +
+
+
+ + +
+
+ @@ -132,18 +227,18 @@ if (empty($_SESSION['csrf_token'])) {
-
Fichier de configuration
+
Stockage du hash
- config/admin_credentials.php - + site_settings (DB) +

- Aucun mot de passe PHP configuré. Le formulaire ci-dessous créera - config/admin_credentials.php avec un hash bcrypt. + Aucun mot de passe PHP configuré. Le formulaire ci-dessous stockera + un hash bcrypt dans la base de données.

@@ -183,20 +278,40 @@ if (empty($_SESSION['csrf_token'])) {
Supprimer la configuration du mot de passe PHP

- Supprime config/admin_credentials.php. 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.

+ onsubmit="return confirm('Supprimer le mot de passe PHP ? L\'accès admin ne sera protégé que par nginx Basic Auth.')">> - +
+ + + +
+

Exporter la base de données

+ +
+ +

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.

+ + +
diff --git a/public/admin/status.php b/app/public/admin/status.php similarity index 100% rename from public/admin/status.php rename to app/public/admin/status.php diff --git a/public/admin/system-fragment.php b/app/public/admin/system-fragment.php similarity index 98% rename from public/admin/system-fragment.php rename to app/public/admin/system-fragment.php index 131a5fc..eaf3d7a 100644 --- a/public/admin/system-fragment.php +++ b/app/public/admin/system-fragment.php @@ -9,11 +9,11 @@ * Response: text/html fragment (no // 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); diff --git a/public/admin/system.php b/app/public/admin/system.php similarity index 99% rename from public/admin/system.php rename to app/public/admin/system.php index 04656ef..1914aad 100644 --- a/public/admin/system.php +++ b/app/public/admin/system.php @@ -1,9 +1,9 @@

Mots-clés ()

- + diff --git a/public/admin/thanks.php b/app/public/admin/thanks.php similarity index 98% rename from public/admin/thanks.php rename to app/public/admin/thanks.php index 80918d3..60a6be4 100644 --- a/public/admin/thanks.php +++ b/app/public/admin/thanks.php @@ -1,6 +1,6 @@ Récapitulatif TFE -

+

Retour au formulaire

diff --git a/public/apropos.php b/app/public/apropos.php similarity index 99% rename from public/apropos.php rename to app/public/apropos.php index a3b4216..62bc661 100644 --- a/public/apropos.php +++ b/app/public/apropos.php @@ -1,5 +1,5 @@ handle(); diff --git a/public/licence.php b/app/public/licence.php similarity index 96% rename from public/licence.php rename to app/public/licence.php index 1606051..a334fb3 100644 --- a/public/licence.php +++ b/app/public/licence.php @@ -1,5 +1,5 @@ /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(); diff --git a/public/partage/thanks.php b/app/public/partage/thanks.php similarity index 97% rename from public/partage/thanks.php rename to app/public/partage/thanks.php index df14015..6364f07 100644 --- a/public/partage/thanks.php +++ b/app/public/partage/thanks.php @@ -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(); diff --git a/public/repertoire.php b/app/public/repertoire.php similarity index 86% rename from public/repertoire.php rename to app/public/repertoire.php index d2058ea..f875465 100644 --- a/public/repertoire.php +++ b/app/public/repertoire.php @@ -1,6 +1,6 @@ and /partage// 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; } diff --git a/src/AdminAuth.php b/app/src/AdminAuth.php similarity index 50% rename from src/AdminAuth.php rename to app/src/AdminAuth.php index 43d402c..71ce73e 100644 --- a/src/AdminAuth.php +++ b/app/src/AdminAuth.php @@ -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). */ diff --git a/src/App.php b/app/src/App.php similarity index 100% rename from src/App.php rename to app/src/App.php diff --git a/app/src/Controllers/AboutController.php b/app/src/Controllers/AboutController.php new file mode 100644 index 0000000..d38ad56 --- /dev/null +++ b/app/src/Controllers/AboutController.php @@ -0,0 +1,43 @@ +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', + ]; + } +} diff --git a/app/src/Controllers/ExportController.php b/app/src/Controllers/ExportController.php new file mode 100644 index 0000000..232a1b6 --- /dev/null +++ b/app/src/Controllers/ExportController.php @@ -0,0 +1,166 @@ +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> 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; + } +} diff --git a/src/HomeController.php b/app/src/Controllers/HomeController.php similarity index 100% rename from src/HomeController.php rename to app/src/Controllers/HomeController.php diff --git a/app/src/Controllers/LicenceController.php b/app/src/Controllers/LicenceController.php new file mode 100644 index 0000000..9aaaea7 --- /dev/null +++ b/app/src/Controllers/LicenceController.php @@ -0,0 +1,36 @@ +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', + ]; + } +} diff --git a/app/src/Controllers/LiveReloadController.php b/app/src/Controllers/LiveReloadController.php new file mode 100644 index 0000000..fd28eb4 --- /dev/null +++ b/app/src/Controllers/LiveReloadController.php @@ -0,0 +1,57 @@ +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]; + } +} diff --git a/app/src/Controllers/MediaController.php b/app/src/Controllers/MediaController.php new file mode 100644 index 0000000..10067f1 --- /dev/null +++ b/app/src/Controllers/MediaController.php @@ -0,0 +1,114 @@ +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); + } +} diff --git a/src/SearchController.php b/app/src/Controllers/SearchController.php similarity index 100% rename from src/SearchController.php rename to app/src/Controllers/SearchController.php diff --git a/src/SystemController.php b/app/src/Controllers/SystemController.php similarity index 100% rename from src/SystemController.php rename to app/src/Controllers/SystemController.php diff --git a/src/TfeController.php b/app/src/Controllers/TfeController.php similarity index 100% rename from src/TfeController.php rename to app/src/Controllers/TfeController.php diff --git a/src/ThesisCreateController.php b/app/src/Controllers/ThesisCreateController.php similarity index 100% rename from src/ThesisCreateController.php rename to app/src/Controllers/ThesisCreateController.php diff --git a/src/ThesisEditController.php b/app/src/Controllers/ThesisEditController.php similarity index 100% rename from src/ThesisEditController.php rename to app/src/Controllers/ThesisEditController.php diff --git a/src/Database.php b/app/src/Database.php similarity index 95% rename from src/Database.php rename to app/src/Database.php index 0a03c7c..0783718 100644 --- a/src/Database.php +++ b/app/src/Database.php @@ -1,6 +1,6 @@ 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 // ======================================================================== diff --git a/app/src/Dispatcher.php b/app/src/Dispatcher.php new file mode 100644 index 0000000..36bf265 --- /dev/null +++ b/app/src/Dispatcher.php @@ -0,0 +1,151 @@ + → TfeController → tfe view + * /apropos → AboutController → about view + * /licence → LicenceController → licence view + * /media.php → MediaController (direct output) + * /live-reload → LiveReloadController (direct output) + * /partage/ → 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 '

404 — Page non trouvée

'; + 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; + } +} diff --git a/src/Parsedown.php b/app/src/Parsedown.php similarity index 100% rename from src/Parsedown.php rename to app/src/Parsedown.php diff --git a/src/RateLimit.php b/app/src/RateLimit.php similarity index 100% rename from src/RateLimit.php rename to app/src/RateLimit.php diff --git a/src/ShareLink.php b/app/src/ShareLink.php similarity index 100% rename from src/ShareLink.php rename to app/src/ShareLink.php diff --git a/app/src/SmtpRelay.php b/app/src/SmtpRelay.php new file mode 100644 index 0000000..a3f0db0 --- /dev/null +++ b/app/src/SmtpRelay.php @@ -0,0 +1,181 @@ +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); + } +} diff --git a/src/SystemCache.php b/app/src/SystemCache.php similarity index 100% rename from src/SystemCache.php rename to app/src/SystemCache.php diff --git a/storage/.gitkeep b/app/storage/.gitkeep similarity index 100% rename from storage/.gitkeep rename to app/storage/.gitkeep diff --git a/storage/Database_TFE_test.csv b/app/storage/Database_TFE_test.csv similarity index 100% rename from storage/Database_TFE_test.csv rename to app/storage/Database_TFE_test.csv diff --git a/storage/README.md b/app/storage/README.md similarity index 100% rename from storage/README.md rename to app/storage/README.md diff --git a/storage/db.sqlite b/app/storage/cache/.gitkeep similarity index 100% rename from storage/db.sqlite rename to app/storage/cache/.gitkeep diff --git a/app/storage/cache/rate_limit/ad921d60486366258809553a3db49a4a.json b/app/storage/cache/rate_limit/ad921d60486366258809553a3db49a4a.json new file mode 100644 index 0000000..3da2b3d --- /dev/null +++ b/app/storage/cache/rate_limit/ad921d60486366258809553a3db49a4a.json @@ -0,0 +1 @@ +[1776449542] \ No newline at end of file diff --git a/storage/fixtures/CreateTestDatabase.php b/app/storage/fixtures/CreateTestDatabase.php similarity index 100% rename from storage/fixtures/CreateTestDatabase.php rename to app/storage/fixtures/CreateTestDatabase.php diff --git a/storage/migrations/001_rename_keywords_to_tags.sql b/app/storage/migrations/001_rename_keywords_to_tags.sql similarity index 100% rename from storage/migrations/001_rename_keywords_to_tags.sql rename to app/storage/migrations/001_rename_keywords_to_tags.sql diff --git a/storage/migrations/002_add_visibility.sql b/app/storage/migrations/002_add_visibility.sql similarity index 100% rename from storage/migrations/002_add_visibility.sql rename to app/storage/migrations/002_add_visibility.sql diff --git a/storage/migrations/003_seed_license_types.sql b/app/storage/migrations/003_seed_license_types.sql similarity index 100% rename from storage/migrations/003_seed_license_types.sql rename to app/storage/migrations/003_seed_license_types.sql diff --git a/storage/migrations/004_jury_roles.sql b/app/storage/migrations/004_jury_roles.sql similarity index 100% rename from storage/migrations/004_jury_roles.sql rename to app/storage/migrations/004_jury_roles.sql diff --git a/storage/migrations/005_add_banner.sql b/app/storage/migrations/005_add_banner.sql similarity index 100% rename from storage/migrations/005_add_banner.sql rename to app/storage/migrations/005_add_banner.sql diff --git a/storage/migrations/006_add_composite_index.sql b/app/storage/migrations/006_add_composite_index.sql similarity index 100% rename from storage/migrations/006_add_composite_index.sql rename to app/storage/migrations/006_add_composite_index.sql diff --git a/storage/migrations/007_system_cache.sql b/app/storage/migrations/007_system_cache.sql similarity index 100% rename from storage/migrations/007_system_cache.sql rename to app/storage/migrations/007_system_cache.sql diff --git a/storage/migrations/008_formulaire_settings.sql b/app/storage/migrations/008_formulaire_settings.sql similarity index 100% rename from storage/migrations/008_formulaire_settings.sql rename to app/storage/migrations/008_formulaire_settings.sql diff --git a/storage/migrations/009_share_links.sql b/app/storage/migrations/009_share_links.sql similarity index 100% rename from storage/migrations/009_share_links.sql rename to app/storage/migrations/009_share_links.sql diff --git a/storage/migrations/010_apropos_contents.sql b/app/storage/migrations/010_apropos_contents.sql similarity index 100% rename from storage/migrations/010_apropos_contents.sql rename to app/storage/migrations/010_apropos_contents.sql diff --git a/storage/migrations/011_apropos_entries.sql b/app/storage/migrations/011_apropos_entries.sql similarity index 100% rename from storage/migrations/011_apropos_entries.sql rename to app/storage/migrations/011_apropos_entries.sql diff --git a/app/storage/migrations/012_smtp_settings.sql b/app/storage/migrations/012_smtp_settings.sql new file mode 100644 index 0000000..1ee356c --- /dev/null +++ b/app/storage/migrations/012_smtp_settings.sql @@ -0,0 +1,22 @@ +-- SMTP relay credentials stored in the database. +-- A single active row is read at send-time for flexibility (change provider, +-- rotate passwords, etc. without touching code or env vars). + +CREATE TABLE IF NOT EXISTS smtp_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), -- singleton row + host TEXT NOT NULL DEFAULT '', + port INTEGER NOT NULL DEFAULT 587, + encryption TEXT NOT NULL DEFAULT 'tls', -- 'tls' | 'ssl' | 'none' + username TEXT NOT NULL DEFAULT '', + password TEXT NOT NULL DEFAULT '', -- stored in clear for now; encrypt later + from_email TEXT NOT NULL DEFAULT '', + from_name TEXT NOT NULL DEFAULT 'Post-ERG', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Insert default empty row so the settings form can start working immediately. +INSERT OR IGNORE INTO smtp_settings (id) VALUES (1); + +-- Helper view so callers always read the same row. +CREATE VIEW IF NOT EXISTS v_smtp_active AS +SELECT * FROM smtp_settings WHERE id = 1; diff --git a/app/storage/migrations/013_admin_password.sql b/app/storage/migrations/013_admin_password.sql new file mode 100644 index 0000000..bb72888 --- /dev/null +++ b/app/storage/migrations/013_admin_password.sql @@ -0,0 +1,11 @@ +-- Migration 013: Store admin password hash in site_settings +-- +-- Previously stored in config/admin_credentials.php as the constant ADMIN_PASSWORD_HASH. +-- Now stored alongside SMTP credentials in the site_settings key-value table. +-- +-- After applying this migration, import your existing hash manually: +-- UPDATE site_settings SET value = '$2y$12$...' WHERE key = 'admin_password_hash'; +-- Or simply set a new one via the admin panel UI. + +INSERT OR IGNORE INTO site_settings (key, value) VALUES + ('admin_password_hash', ''); diff --git a/storage/posterg.db b/app/storage/posterg.db similarity index 96% rename from storage/posterg.db rename to app/storage/posterg.db index 0a75020..b66f21a 100644 Binary files a/storage/posterg.db and b/app/storage/posterg.db differ diff --git a/storage/schema.sql b/app/storage/schema.sql similarity index 95% rename from storage/schema.sql rename to app/storage/schema.sql index 5469e59..59887ef 100644 --- a/storage/schema.sql +++ b/app/storage/schema.sql @@ -292,7 +292,8 @@ CREATE TABLE IF NOT EXISTS site_settings ( INSERT OR IGNORE INTO site_settings (key, value) VALUES ('access_type_interdit_enabled', '1'), ('access_type_interne_enabled', '1'), - ('access_type_libre_enabled', '0'); + ('access_type_libre_enabled', '0'), + ('admin_password_hash', ''); -- ============================================================================ -- STATIC PAGES / CONTENT MANAGEMENT @@ -321,6 +322,25 @@ INSERT OR IGNORE INTO pages (slug, title, content) VALUES ('about', 'À propos', 'Contenu à venir'), ('licenses', 'Licences', 'Contenu à venir'); +-- ============================================================================ +-- SMTP SETTINGS +-- ============================================================================ + +-- Singleton row — id is always 1. Credentials stored in clear for now. +CREATE TABLE IF NOT EXISTS smtp_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + host TEXT NOT NULL DEFAULT '', + port INTEGER NOT NULL DEFAULT 587, + encryption TEXT NOT NULL DEFAULT 'tls', -- 'tls' | 'ssl' | 'none' + username TEXT NOT NULL DEFAULT '', + password TEXT NOT NULL DEFAULT '', -- stored in clear for now; encrypt later + from_email TEXT NOT NULL DEFAULT '', + from_name TEXT NOT NULL DEFAULT 'Post-ERG', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +INSERT OR IGNORE INTO smtp_settings (id) VALUES (1); + -- ============================================================================ -- APROPOS CONTENTS (structured data for the "À propos" page) -- ============================================================================ diff --git a/app/storage/test.db b/app/storage/test.db new file mode 100644 index 0000000..b2a0e05 Binary files /dev/null and b/app/storage/test.db differ diff --git a/app/templates/admin/footer.php b/app/templates/admin/footer.php new file mode 100644 index 0000000..8343268 --- /dev/null +++ b/app/templates/admin/footer.php @@ -0,0 +1,39 @@ + + + +
+ + + + + + + + + + + diff --git a/templates/footer.php b/app/templates/footer.php similarity index 100% rename from templates/footer.php rename to app/templates/footer.php diff --git a/templates/head.php b/app/templates/head.php similarity index 100% rename from templates/head.php rename to app/templates/head.php diff --git a/templates/header.php b/app/templates/header.php similarity index 98% rename from templates/header.php rename to app/templates/header.php index 3373c27..2ef9765 100644 --- a/templates/header.php +++ b/app/templates/header.php @@ -25,7 +25,7 @@ $_thesisId = $_GET['id'] ?? null;
  • >Modifier
  • - +
  • Déconnexion
  • diff --git a/app/templates/partials/flash-messages.php b/app/templates/partials/flash-messages.php new file mode 100644 index 0000000..7397091 --- /dev/null +++ b/app/templates/partials/flash-messages.php @@ -0,0 +1,21 @@ + + + + diff --git a/templates/partials/form/checkbox-list.php b/app/templates/partials/form/checkbox-list.php similarity index 100% rename from templates/partials/form/checkbox-list.php rename to app/templates/partials/form/checkbox-list.php diff --git a/templates/partials/form/file-field.php b/app/templates/partials/form/file-field.php similarity index 100% rename from templates/partials/form/file-field.php rename to app/templates/partials/form/file-field.php diff --git a/templates/partials/form/jury-fieldset.php b/app/templates/partials/form/jury-fieldset.php similarity index 100% rename from templates/partials/form/jury-fieldset.php rename to app/templates/partials/form/jury-fieldset.php diff --git a/templates/partials/form/select-field.php b/app/templates/partials/form/select-field.php similarity index 100% rename from templates/partials/form/select-field.php rename to app/templates/partials/form/select-field.php diff --git a/templates/partials/form/text-field.php b/app/templates/partials/form/text-field.php similarity index 100% rename from templates/partials/form/text-field.php rename to app/templates/partials/form/text-field.php diff --git a/templates/partials/pagination.php b/app/templates/partials/pagination.php similarity index 100% rename from templates/partials/pagination.php rename to app/templates/partials/pagination.php diff --git a/templates/partials/repertoire-index.php b/app/templates/partials/repertoire-index.php similarity index 100% rename from templates/partials/repertoire-index.php rename to app/templates/partials/repertoire-index.php diff --git a/templates/partials/status-badge.php b/app/templates/partials/status-badge.php similarity index 100% rename from templates/partials/status-badge.php rename to app/templates/partials/status-badge.php diff --git a/app/templates/public/home.php b/app/templates/public/home.php new file mode 100644 index 0000000..4378ec3 --- /dev/null +++ b/app/templates/public/home.php @@ -0,0 +1,49 @@ + +

    + Année : + Réinitialiser +

    + + + + +
    +

    Mémoires de l'ERG

    + + + +
    diff --git a/app/templates/public/licence.php b/app/templates/public/licence.php new file mode 100644 index 0000000..ba4d4da --- /dev/null +++ b/app/templates/public/licence.php @@ -0,0 +1,9 @@ +
    +
    + + + +

    Contenu à venir.

    + +
    +
    diff --git a/templates/search-bar.php b/app/templates/search-bar.php similarity index 100% rename from templates/search-bar.php rename to app/templates/search-bar.php diff --git a/tests/Integration/SearchTest.php b/app/tests/Integration/SearchTest.php similarity index 100% rename from tests/Integration/SearchTest.php rename to app/tests/Integration/SearchTest.php diff --git a/tests/README.md b/app/tests/README.md similarity index 100% rename from tests/README.md rename to app/tests/README.md diff --git a/tests/Security/SecurityTest.php b/app/tests/Security/SecurityTest.php similarity index 100% rename from tests/Security/SecurityTest.php rename to app/tests/Security/SecurityTest.php diff --git a/tests/Unit/DatabaseTest.php b/app/tests/Unit/DatabaseTest.php similarity index 100% rename from tests/Unit/DatabaseTest.php rename to app/tests/Unit/DatabaseTest.php diff --git a/tests/Unit/RateLimitTest.php b/app/tests/Unit/RateLimitTest.php similarity index 100% rename from tests/Unit/RateLimitTest.php rename to app/tests/Unit/RateLimitTest.php diff --git a/tests/run-tests.php b/app/tests/run-tests.php similarity index 100% rename from tests/run-tests.php rename to app/tests/run-tests.php diff --git a/config/admin_credentials.example.php b/config/admin_credentials.example.php deleted file mode 100644 index 5c1a259..0000000 --- a/config/admin_credentials.example.php +++ /dev/null @@ -1,13 +0,0 @@ -&1 | stdbuf -oL grep -E '(Development Server|\[200\])' | stdbuf -oL grep -v 'live-reload\.php' || true + @php -S 127.0.0.1:8000 -t app/public/ app/router.php 2>&1 | stdbuf -oL grep -E '(Development Server|\[200\])' | stdbuf -oL grep -v 'live-reload\.php' || true [group('dev')] stop: @@ -29,7 +29,7 @@ logs: [group('deploy')] deploy: - rsync -vur --progress \ + rsync -vur --progress --delete \ --chown="www-data:posterg" \ --exclude 'vendor' \ --exclude 'tests' \ @@ -44,13 +44,9 @@ deploy: --exclude 'storage/cache/*' \ --exclude 'storage/fixtures' \ --exclude 'storage/docs' \ - --exclude 'nginx' \ - --exclude 'docs' \ - --exclude 'justfile*' \ - --exclude 'scripts' \ --exclude 'var/cache/*' \ --exclude 'var/logs/*' \ - ./ posterg:/var/www/posterg/ + app/ posterg:/var/www/posterg/ ssh posterg "mkdir -p /var/www/posterg/var/{cache,logs,tmp}" [group('deploy')] @@ -85,7 +81,7 @@ deploy-nginx: [group('deploy')] deploy-db: @ssh posterg '[ ! -f /var/www/posterg/storage/test.db ]' || (echo "ERROR: remote database already exists. Remove it manually if you intend to overwrite." && exit 1) - rsync -v --progress ./storage/test.db posterg:/var/www/posterg/storage/test.db + rsync -v --progress app/storage/test.db posterg:/var/www/posterg/storage/test.db ssh posterg "chown www-data:posterg /var/www/posterg/storage/test.db && chmod 660 /var/www/posterg/storage/test.db" # ============================================================================ @@ -94,27 +90,25 @@ deploy-db: [group('test')] test: - @DB_ENV=test php tests/run-tests.php + @DB_ENV=test php app/tests/run-tests.php [group('test')] test-unit: - @DB_ENV=test php tests/Unit/DatabaseTest.php - @DB_ENV=test php tests/Unit/RateLimitTest.php + @DB_ENV=test php app/tests/Unit/DatabaseTest.php + @DB_ENV=test php app/tests/Unit/RateLimitTest.php [group('test')] test-integration: - @DB_ENV=test php tests/Integration/SearchTest.php + @DB_ENV=test php app/tests/Integration/SearchTest.php [group('test')] test-security: - @DB_ENV=test php tests/Security/SecurityTest.php + @DB_ENV=test php app/tests/Security/SecurityTest.php [group('test')] syntax: - @find . -maxdepth 1 -name "*.php" -not -path "./vendor/*" -exec php -l {} \; | grep -v "No syntax errors" - @find admin/ -name "*.php" -exec php -l {} \; 2>/dev/null | grep -v "No syntax errors" || true - @find src/ -name "*.php" -exec php -l {} \; | grep -v "No syntax errors" - @echo "✅ Syntax OK" + @find app/ -name '*.php' -exec php -l {} \; 2>/dev/null | grep -v 'No syntax errors' || true + @echo '✅ Syntax OK' # ============================================================================ # Database @@ -135,29 +129,29 @@ migrate-prod: [group('database')] init-db: - @sqlite3 storage/test.db < storage/schema.sql - @sqlite3 storage/test.db "SELECT COUNT(*) || ' tables' FROM sqlite_master WHERE type='table';" + @sqlite3 app/storage/test.db < app/storage/schema.sql + @sqlite3 app/storage/test.db "SELECT COUNT(*) || ' tables' FROM sqlite_master WHERE type='table';" [group('database')] reset-db: - @rm -f storage/test.db + @rm -f app/storage/test.db @just init-db [group('database')] query: - @sqlite3 storage/test.db + @sqlite3 app/storage/test.db [group('database')] show id: - @sqlite3 -column -header storage/test.db "SELECT * FROM v_theses_full WHERE id = {{id}};" + @sqlite3 -column -header app/storage/test.db "SELECT * FROM v_theses_full WHERE id = {{id}};"; [group('database')] backup: - @sqlite3 storage/test.db .dump > storage/backup_$(date +%Y%m%d_%H%M%S).sql + @sqlite3 app/storage/test.db .dump > app/storage/backup_$(date +%Y%m%d_%H%M%S).sql [group('database')] fixtures: - @php storage/fixtures/CreateTestDatabase.php + @php app/storage/fixtures/CreateTestDatabase.php # ============================================================================ # Utils @@ -165,11 +159,11 @@ fixtures: [group('utils')] clean: - @rm -f error.log admin/error.log - @rm -rf src/cache/rate_limit/* + @rm -f app/error.log + @rm -rf app/storage/cache/rate_limit/* @rm -f /tmp/posterg-*.log /tmp/posterg-*.pid [group('utils')] setup-dirs: - @mkdir -p admin/data/{theses,covers,yaml} src/cache/rate_limit - @touch admin/data/theses/.gitkeep admin/data/covers/.gitkeep + @mkdir -p app/storage/cache/rate_limit + @touch app/storage/cache/rate_limit/.gitkeep diff --git a/nginx/posterg.conf b/nginx/posterg.conf index 899be36..e2221b1 100644 --- a/nginx/posterg.conf +++ b/nginx/posterg.conf @@ -1,5 +1,4 @@ # Nginx configuration for Post-ERG thesis website (Production) -# Updated for new directory structure # Place this in /etc/nginx/sites-available/posterg # Then symlink: ln -s /etc/nginx/sites-available/posterg /etc/nginx/sites-enabled/ @@ -15,24 +14,22 @@ limit_req_zone $binary_remote_addr zone=admin:10m rate=60r/m; server { listen 80 default_server; listen [::]:80 default_server; - + server_name posterg.erg.be www.posterg.erg.be; # Document root points to /public (only web-accessible files) - # Project structure: /var/www/posterg/ - # /config - Configuration (outside webroot) - # /docs - Documentation (outside webroot) - # /nginx - Server configs (outside webroot) + # Deployed structure: /var/www/posterg/ # /public - Web root ← THIS DIRECTORY # /admin - Admin interface # /assets - CSS, fonts, icons - # /scripts - Deployment scripts (outside webroot) # /src - PHP source classes (outside webroot) # /storage - SQLite databases (outside webroot) # /templates - PHP templates (outside webroot) # /tests - Test suites (outside webroot) + # /bootstrap.php - Application entry point + # /router.php - Dev server URL rewriter root /var/www/posterg/public; - + # Add index.php to the list index index.php index.html index.htm; @@ -135,10 +132,10 @@ server { location ~ \.php$ { include snippets/fastcgi-php.conf; fastcgi_pass unix:/var/run/php/php8.4-fpm.sock; - + # Security parameters fastcgi_param PHP_VALUE "upload_max_filesize=50M \n post_max_size=100M"; - + # Timeouts fastcgi_read_timeout 120; fastcgi_send_timeout 120; @@ -146,7 +143,7 @@ server { # Additional security headers for admin add_header X-Robots-Tag "noindex, nofollow" always; - + # Try to serve file, otherwise 404 try_files $uri $uri/ =404; } @@ -173,7 +170,7 @@ server { # Security parameters fastcgi_param PHP_VALUE "upload_max_filesize=50M \n post_max_size=100M"; - + # Timeouts fastcgi_read_timeout 120; fastcgi_send_timeout 120; diff --git a/public/media.php b/public/media.php deleted file mode 100644 index 87df8fc..0000000 --- a/public/media.php +++ /dev/null @@ -1,121 +0,0 @@ -getFileVisibility($requestedPath); - if ($accessTypeId !== null && $accessTypeId === 3) { - // 3 = Interdit — block entirely - http_response_code(403); - exit; - } - // 2 = Interne — allow (no session auth requirement for now; could add later) - } catch (\Throwable $e) { - // On DB error, fail open (don't block legitimate requests) - error_log("media.php visibility check error: " . $e->getMessage()); - } -} - -// --- 3. Verify MIME type from file content (not extension) -------------------- - -$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; -} - -// --- 4. 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)) { - // Images: cache publicly for 7 days - header('Cache-Control: public, max-age=604800'); -} elseif ($ext === 'pdf') { - // PDFs: cache for 1 day, display inline - header('Cache-Control: public, max-age=86400'); - header('Content-Disposition: inline'); -} elseif ($ext === 'vtt') { - // WebVTT captions: serve as text/vtt, cache 1 day - header('Content-Type: text/vtt; charset=utf-8'); - header('Cache-Control: public, max-age=86400'); -} else { - // Everything else: no public caching - header('Cache-Control: private, no-store'); -} - -// --- 5. Stream file ----------------------------------------------------------- - -readfile($realFull); diff --git a/scripts/migrate.sh b/scripts/migrate.sh index 1b1ffc9..0dd1b04 100755 --- a/scripts/migrate.sh +++ b/scripts/migrate.sh @@ -8,9 +8,10 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -MIGRATIONS_DIR="$REPO_ROOT/storage/migrations" -TEST_DB="$REPO_ROOT/storage/test.db" -PROD_DB="$REPO_ROOT/storage/posterg.db" +APP_DIR="$REPO_ROOT/app" +MIGRATIONS_DIR="$APP_DIR/storage/migrations" +TEST_DB="$APP_DIR/storage/test.db" +PROD_DB="$APP_DIR/storage/posterg.db" # --------------------------------------------------------------------------- # Check whether a migration's effects are already present in the DB so that @@ -58,6 +59,15 @@ already_applied_structurally() { col=$(sqlite3 "$db" "SELECT COUNT(*) FROM pragma_table_info('authors') WHERE name='show_contact';") [ "$tbl" -eq 1 ] && [ "$col" -eq 1 ] ;; + 012_smtp_settings.sql) + tbl=$(sqlite3 "$db" "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='smtp_settings';") + [ "$tbl" -eq 1 ] + ;; + 013_admin_password.sql) + # Effect: admin_password_hash key exists in site_settings (INSERT OR IGNORE — safe to re-run) + count=$(sqlite3 "$db" "SELECT COUNT(*) FROM site_settings WHERE key='admin_password_hash';") + [ "$count" -eq 1 ] + ;; *) # Unknown migration — assume not applied return 1 @@ -74,7 +84,7 @@ migrate_db() { table_count=$(sqlite3 "$db" "SELECT COUNT(*) FROM sqlite_master WHERE type='table';" 2>/dev/null || echo 0) if [ "$table_count" -eq 0 ]; then echo " [$label] initialising from schema…" - sqlite3 "$db" < "$REPO_ROOT/storage/schema.sql" + sqlite3 "$db" < "$APP_DIR/storage/schema.sql" echo " [$label] schema applied." fi diff --git a/storage/thesis.db b/storage/thesis.db deleted file mode 100644 index e69de29..0000000 diff --git a/templates/admin/footer.php b/templates/admin/footer.php deleted file mode 100644 index 770d4df..0000000 --- a/templates/admin/footer.php +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/templates/partials/flash-messages.php b/templates/partials/flash-messages.php deleted file mode 100644 index e7e8457..0000000 --- a/templates/partials/flash-messages.php +++ /dev/null @@ -1,18 +0,0 @@ - - -

    - - -

    - diff --git a/test.db b/test.db deleted file mode 100644 index e69de29..0000000