diff --git a/LDAP_SPEC.md b/LDAP_SPEC.md new file mode 100644 index 0000000..eca01e3 --- /dev/null +++ b/LDAP_SPEC.md @@ -0,0 +1,141 @@ +# LDAP Authentication Specification for XAMXAM Admin + +## Current state + +Two-layer authentication guards the `/admin/` area: + +| Layer | Mechanism | Where | +|-------|-----------|-------| +| 1 (nginx) | `auth_basic` against `/etc/nginx/.htpasswd-xamxam` | `nginx/xamxam.conf` | +| 2 (PHP) | `AdminAuth` — bcrypt hash in `site_settings.admin_password_hash` | `app/src/AdminAuth.php`, `app/public/admin/login.php`, `app/public/admin/actions/account.php` | + +Layer 1 controls the browser's Basic Auth dialog. Layer 2 provides a PHP session gate and a +fallback login form. When both layers share the same password, the user is authenticated +transparently (nginx passes `PHP_AUTH_PW` to PHP, `AdminAuth` verifies it against the DB hash). + +## Goal + +Replace both layers with LDAP-based authentication while preserving the defence-in-depth +structure and the transparent user experience (single sign-on via the browser's Basic Auth +dialog, no PHP login form unless fallback). + +## Required information from IT + +| # | Item | Example / format | +|---|------|------------------| +| 1 | **LDAP server URL** | `ldaps://ldap.erg.be:636` or `ldap://ldap.erg.be:389` | +| 2 | **Base DN** | `dc=erg,dc=be` | +| 3 | **Bind DN** (service / search account) | `cn=svc-xamxam,ou=services,dc=erg,dc=be` | +| 4 | **Bind password** | (secret — read-only account is sufficient) | +| 5 | **User search filter** | `(&(uid=%s)(memberOf=cn=admin-xamxam,ou=groups,dc=erg,dc=be))` — `%s` is the username entered in the Basic Auth dialog | +| 6 | **Group membership mechanism** | `memberOf` attribute (AD-style) **or** `member`/`uniqueMember` on the group entry (OpenLDAP-style) | +| 7 | **Username attribute** | Typically `uid` (OpenLDAP) or `sAMAccountName` (AD). What attribute should the user type in the auth dialog? | +| 8 | **TLS certificate** | If `ldaps://` is used and the certificate is self-signed, provide the CA certificate (PEM). Otherwise confirm it's a publicly-trusted cert. | +| 9 | **Admin group DN/CN** | The exact DN or CN that grants admin access (e.g. `cn=xamxam-admins,ou=groups,dc=erg,dc=be`). If there's no group yet, what should it be named? | + +## Architecture + +``` +Browser Nginx LDAP daemon LDAP server + │ │ │ │ + │─ GET /admin/ ──────────►│ │ │ + │◄── 401 WWW-Authenticate │ │ │ + │─ GET /admin/ + Basic ──►│ │ │ + │ │─ POST /auth-ldap ───────────►│ │ + │ │ (proxy Authorization hdr) │─ ldap_bind ──────────►│ + │ │ │◄── success ───────────│ + │ │ │─ ldap_search ────────►│ + │ │ │◄── group check OK ────│ + │ │◄── 200 OK ──────────────────│ │ + │ │─ forward to PHP ────────────► │ + │ │ │ │ + │◄── admin page ─────────│ │ +``` + +### Option A — `nginx-ldap-auth` daemon (preferred) + +- Drop-in replacement for `auth_basic` / `.htpasswd` using nginx's `auth_request` module +- A small Python 3 daemon (`nginx-ldap-auth`) runs at `127.0.0.1:8888` +- Configured via `/etc/nginx-ldap-auth.conf` (JSON or YAML) +- Nginx proxies the `Authorization` header to the daemon; daemon binds to LDAP, + checks group membership, returns 200 or 403 +- **The PHP `AdminAuth` layer remains** — it receives `PHP_AUTH_PW` from nginx, + can verify the username against LDAP group membership, and establish the PHP session + +Nginx config (add to `location ^~ /admin/`): + +```nginx +location ^~ /admin/ { + # Replace auth_basic + auth_basic_user_file with: + auth_request /auth-ldap; + auth_request_set $saved_set_cookie $upstream_http_set_cookie; + add_header Set-Cookie $saved_set_cookie; + + # Client-facing Basic Auth challenge (so the browser asks for credentials) + satisfy any; + # Fallback: if auth_request returns 401, challenge + error_page 401 = @ldap_challenge; + + # Keep: rate limiting, CSP, PHP handling, security headers + limit_req zone=admin burst=20 nodelay; + # ... rest as-is ... +} + +# Internal endpoint — delegates to LDAP daemon +location = /auth-ldap { + internal; + proxy_pass http://127.0.0.1:8888; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header Authorization $http_authorization; +} + +# Trigger browser Basic Auth dialog when LDAP returns 401 +location @ldap_challenge { + add_header WWW-Authenticate 'Basic realm="Admin Access - XAMXAM"'; + return 401; +} +``` + +### Option B — `ngx_http_auth_ldap_module` (native nginx module) + +- Requires recompiling nginx with this third-party module +- Simpler config: `auth_ldap "XAMXAM Admin"; auth_ldap_servers { ... }` +- Less flexible; harder to debug + +### Option C — PHP-only LDAP (no nginx layer) + +- Remove nginx auth entirely +- `AdminAuth::requireLogin()` does `ldap_bind()` + group check directly in PHP +- Simpler nginx config, but no nginx-level gate +- Browser auth dialog still possible via PHP sending `WWW-Authenticate` header + +## After LDAP is working: cleanup checklist + +| Step | File(s) affected | Action | +|------|-----------------|--------| +| 1 | `app/src/AdminAuth.php` | Remove `getStoredHash()`, `setPasswordHash()`, `removePasswordHash()`, `hasPassword()`, `verifyHash()`. Keep `requireLogin()`, `isAuthenticated()`, `login()`, `logout()` — adapt them to LDAP group check. | +| 2 | `app/public/admin/login.php` | Remove entirely (no more PHP login form). | +| 3 | `app/public/admin/actions/account.php` | Remove entirely (no more password CRUD). | +| 4 | `app/templates/admin/login.php` | Remove template file. | +| 5 | `app/templates/admin/parametres.php` | Remove the "Compte administrateur" `
` (password set/change/delete UI). | +| 6 | `app/public/admin/parametres.php` | Remove `AdminAuth::hasPassword()` call and related variables. | +| 7 | `app/templates/admin/account.php` | Remove if only used for password management. | +| 8 | `nginx/xamxam.conf` | Remove `auth_basic` and `auth_basic_user_file` lines from the admin location block. | +| 9 | Database | Remove `admin_password_hash` row from `site_settings` table (manual or migration). | +| 10 | `app/bootstrap.php` | Remove legacy `ADMIN_PASSWORD_HASH` constant reference if present. | + +## Dependencies to install + +- **Option A**: Python 3, `python3-ldap` (or `pip install python-ldap`), `nginx-ldap-auth` daemon +- **Option B**: nginx recompiled with `ngx_http_auth_ldap_module` +- **Option C**: PHP `ldap` extension (`php8.4-ldap` or `apt install php-ldap`) + +## Notes + +- The `AdminAuth` PHP layer should remain even after LDAP is implemented — it provides + session persistence, logout, CSRF integration, and the admin audit log identity. +- The LDAP daemon/nginx layer handles **authentication** (who are you?). + The PHP `AdminAuth` layer handles **session management** (are you still you?). +- If IT provides a dedicated admin group, access control is centralised: adding/removing + an admin is a single LDAP operation, no need to touch the server. diff --git a/TODO.md b/TODO.md index dfbe62b..ccd1562 100644 --- a/TODO.md +++ b/TODO.md @@ -98,3 +98,11 @@ - [x] Update all PHP templates to use new `.btn` classes (`btn btn--primary`, `btn btn--secondary`, `btn btn--danger`, etc.) - [x] Update border-radius on pagination buttons to 10px for consistency - [x] Exclude `storage/maintenance.flag` from rsync deploy and git + +## Admin file export system +- [x] `ExportController`: add `getAllThesisFiles()`, `buildExportManifest()`, `createExportZip()` — gathers all thesis files, creates zip with files/ + manifest.json +- [x] `admin/actions/export-files.php` — thin dispatcher, streams zip, logs audit +- [x] `AdminLogger::logFilesExport()` — audit log entry for file exports +- [x] `templates/admin/index.php` — add "Exporter fichiers" button next to CSV export button +- [x] `Database::getAllThesisFilesForExport()` — query all thesis_files + identifier +- [x] `docs/export.md` — documentation en français pour administrateurs : fonctionnement, contenu du ZIP, procédure de restauration complète et partielle diff --git a/app/public/admin/actions/export-files.php b/app/public/admin/actions/export-files.php new file mode 100644 index 0000000..8971e73 --- /dev/null +++ b/app/public/admin/actions/export-files.php @@ -0,0 +1,48 @@ +createExportZip(); + + $fileSize = filesize($zipPath); + $fileCount = count($controller->getAllThesisFiles()); + + // Audit log (before headers in case log fails — but it's best-effort anyway) + AdminLogger::make()->logFilesExport($fileCount, (int)$fileSize); + + $filename = 'xamxam-files-' . date('Y-m-d') . '.zip'; + + header('Content-Type: application/zip'); + header('Content-Disposition: attachment; filename="' . $filename . '"'); + header('Content-Length: ' . $fileSize); + header('Cache-Control: no-cache, must-revalidate'); + + readfile($zipPath); + + // Clean up temp file + @unlink($zipPath); +} catch (Exception $e) { + error_log('Files export error: ' . $e->getMessage()); + http_response_code(500); + exit('Erreur lors de la création de l\'archive : ' . htmlspecialchars($e->getMessage())); +} + +exit; diff --git a/app/src/AdminLogger.php b/app/src/AdminLogger.php index 046ea76..71e70c4 100644 --- a/app/src/AdminLogger.php +++ b/app/src/AdminLogger.php @@ -185,6 +185,15 @@ class AdminLogger $this->write('system', 'db_export', 'success'); } + /** Files export (ZIP with all thesis files + manifest) */ + public function logFilesExport(int $fileCount, int $byteSize): void + { + $this->write('system', 'files_export', 'success', [ + 'file_count' => $fileCount, + 'byte_size' => $byteSize, + ]); + } + /** Parametres: delete all TFEs */ public function logDeleteAllTheses(int $count): void { diff --git a/app/src/Controllers/ExportController.php b/app/src/Controllers/ExportController.php index bb123cb..4f160af 100644 --- a/app/src/Controllers/ExportController.php +++ b/app/src/Controllers/ExportController.php @@ -37,6 +37,146 @@ class ExportController return $this->db->getDatabasePath(); } + // ── Files export ──────────────────────────────────────────────────── + + /** + * Fetch all thesis file records with their thesis identifier. + * + * @return list + */ + public function getAllThesisFiles(): array + { + return $this->db->getAllThesisFilesForExport(); + } + + /** + * Build a JSON manifest describing every thesis and its files. + * + * The manifest maps identifier → { title, files: [{type, path, name, size, mime, hash, label}] } + * and is used on restore to re-link files to DB records. + * + * @return array + */ + public function buildExportManifest(): array + { + $files = $this->getAllThesisFiles(); + $theses = $this->db->getAllThesesForExport(); + + // Index theses by id for O(1) lookup + $byId = []; + foreach ($theses as $t) { + $byId[(int) $t['id']] = $t; + } + + $entries = []; + foreach ($files as $f) { + $tid = (int) $f['thesis_id']; + $t = $byId[$tid] ?? null; + $key = $t['identifier'] ?? ('id_' . $tid); + + if (!isset($entries[$key])) { + $entries[$key] = [ + 'id' => $tid, + 'identifier' => $t['identifier'] ?? null, + 'title' => $t['title'] ?? '(inconnu)', + 'year' => $t['year'] ?? 0, + 'files' => [], + ]; + } + + $entries[$key]['files'][] = [ + 'type' => $f['file_type'], + 'path' => $f['file_path'], + 'name' => $f['file_name'], + 'size' => $f['file_size'], + 'mime' => $f['mime_type'], + 'hash' => $f['file_hash'] ?? null, + 'label' => $f['display_label'] ?? null, + 'sort_order' => (int) $f['sort_order'], + ]; + } + + return [ + 'exported_at' => date('c'), + 'db_file' => basename($this->db->getDatabasePath()), + 'total_theses' => count($entries), + 'total_files' => count($files), + 'theses' => $entries, + ]; + } + + /** + * Create a zip archive containing all thesis files under a files/ + * directory and a manifest.json at the root. + * + * Returns the path to the temporary zip file. Caller is responsible + * for unlink() after streaming. + * + * @param string|null $baseDir Base directory path for files inside the zip. + * Defaults to "files" (so files are at "files/theses/..."). + * @return string Absolute path to the generated zip file. + * @throws Exception if zip creation fails. + */ + public function createExportZip(?string $baseDir = null): string + { + $baseDir = $baseDir ?? 'files'; + $storageRoot = defined('STORAGE_ROOT') ? STORAGE_ROOT : APP_ROOT . '/storage'; + $files = $this->getAllThesisFiles(); + $manifest = $this->buildExportManifest(); + + $tmpPath = tempnam(sys_get_temp_dir(), 'xamxam-export-'); + if ($tmpPath === false) { + throw new Exception('Impossible de créer un fichier temporaire.'); + } + + // tempnam creates a regular file; we want a .zip file instead. + unlink($tmpPath); + $tmpPath .= '.zip'; + + $zip = new ZipArchive(); + if ($zip->open($tmpPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new Exception('Impossible de créer l\'archive ZIP.'); + } + + // Add manifest.json at the root + $zip->addFromString( + 'manifest.json', + json_encode($manifest, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) + ); + + // Add every thesis file under files/ + $addedCount = 0; + $skippedCount = 0; + foreach ($files as $f) { + $fullPath = $storageRoot . '/' . $f['file_path']; + if (!is_file($fullPath) || !is_readable($fullPath)) { + $skippedCount++; + continue; + } + $zipPath = $baseDir . '/' . $f['file_path']; + $zip->addFile($fullPath, $zipPath); + $addedCount++; + } + + $zip->addFromString( + 'manifest.json', + json_encode(array_merge($manifest, [ + 'zip_skipped_count' => $skippedCount, + 'zip_added_count' => $addedCount, + ]), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) + ); + + if (!$zip->close()) { + @unlink($tmpPath); + throw new Exception('Erreur lors de la finalisation de l\'archive ZIP.'); + } + + return $tmpPath; + } + // ── CSV export ─────────────────────────────────────────────────────── /** @@ -76,7 +216,7 @@ class ExportController */ public function exportAllTheses(): array { - // 1) Base thesis data + // 1) Base thesis data (includes license_name via migration; fallback to license_type from the view) $theses = $this->db->getAllThesesForExport(); if ($theses === []) { return []; diff --git a/app/src/Database.php b/app/src/Database.php index 35f1559..d571963 100644 --- a/app/src/Database.php +++ b/app/src/Database.php @@ -2188,6 +2188,21 @@ class Database ')->fetchAll(); } + /** + * All thesis files for the file-export ZIP. + * Includes every thesis_files column + the thesis identifier for manifest + * construction. + */ + public function getAllThesisFilesForExport(): array + { + return $this->pdo->query(' + SELECT tf.*, t.identifier + FROM thesis_files tf + JOIN theses t ON t.id = tf.thesis_id + ORDER BY t.year DESC, t.title ASC, tf.sort_order ASC + ')->fetchAll(); + } + // ======================================================================== // SINGLETON PATTERN ENFORCEMENT // ======================================================================== diff --git a/app/templates/admin/index.php b/app/templates/admin/index.php index 92ffe48..01eb88e 100644 --- a/app/templates/admin/index.php +++ b/app/templates/admin/index.php @@ -134,6 +134,9 @@ document.addEventListener('DOMContentLoaded', () => { Exporter CSV + + Exporter fichiers + diff --git a/docs/export.md b/docs/export.md new file mode 100644 index 0000000..2805403 --- /dev/null +++ b/docs/export.md @@ -0,0 +1,197 @@ +# Export et sauvegarde des données XAMXAM + +Ce document explique le fonctionnement du système d'export pour les +administrateur·ices de la plateforme. Il couvre les trois types d'export +disponibles depuis le panneau d'administration, la manière de les combiner +pour une restauration complète, et les détails techniques utiles en cas de +problème. + +--- + +## Les trois exports disponibles + +Depuis la page principale d'administration (`/admin/`), la barre d'outils +propose trois boutons d'export : + +| Bouton | Fichier produit | Contenu | +|---|---|---| +| **Exporter CSV** | `xamxam-export-AAAA-MM-JJ.csv` | Liste complète des TFE au format compatible avec l'import CSV | +| **Exporter DB** | `xamxam-db-AAAA-MM-JJ.sqlite` | Copie brute du fichier de base de données SQLite | +| **Exporter fichiers** | `xamxam-files-AAAA-MM-JJ.zip` | Archive ZIP contenant tous les fichiers attachés aux TFE + un manifeste JSON | + +--- + +## Exporter les fichiers (ZIP) + +### Ce que contient l'archive + +``` +xamxam-files-2026-05-07.zip +├── manifest.json ← métadonnées de chaque TFE et ses fichiers +└── files/ + └── theses/ + ├── 2025/ + │ ├── 2025_EMMA_RENARD/ + │ │ └── EMMA_RENARD_carte_loire_a_velo_france.pdf + │ ├── 2025_LILA_DUBOIS_KARIM_NASSAR/ + │ │ ├── nixing_the_fix_report_final.pdf + │ │ ├── bbb_sunflower_1080p_30fps_normal_mp4.zip + │ │ └── carte_loire_a_velo_france.pdf + │ └── … + └── 2026/ + └── … +``` + +### Le fichier `manifest.json` + +```json +{ + "exported_at": "2026-05-07T15:30:00+02:00", + "db_file": "xamxam.db", + "total_theses": 140, + "total_files": 312, + "zip_added_count": 310, + "zip_skipped_count": 2, + "theses": { + "2025-003": { + "id": 42, + "identifier": "2025-003", + "title": "Titre du TFE", + "year": 2025, + "files": [ + { + "type": "main", + "path": "theses/2025/2025_AUTEUR/mon_fichier.pdf", + "name": "mon_fichier.pdf", + "size": 1234567, + "mime": "application/pdf", + "hash": null, + "label": "Mémoire principal", + "sort_order": 1 + } + ] + } + } +} +``` + +Le manifeste fait le **pont entre la base de données et les fichiers**. Chaque +TFE est indexé par son identifiant (ex. `2025-003`), ce qui permet de +retrouver ses fichiers même si l'ordre des IDs change après une restauration. + +--- + +## Restaurer une sauvegarde complète + +Une sauvegarde complète se compose de **trois fichiers** à exporter le même +jour (DB + fichiers ZIP + CSV). Voici la procédure de restauration sur un +serveur neuf ou après un incident. + +### 1. Restaurer la base de données + +```bash +# Arrêter le serveur web +sudo systemctl stop nginx + +# Remplacer le fichier SQLite +cp xamxam-db-AAAA-MM-JJ.sqlite /var/www/xamxam/storage/xamxam.db +chown www-data:www-data /var/www/xamxam/storage/xamxam.db +chmod 640 /var/www/xamxam/storage/xamxam.db +``` + +### 2. Restaurer les fichiers + +```bash +# Décompresser l'archive dans le répertoire storage/ +# ATTENTION : le zip contient un dossier files/ à la racine. +# On le décompresse dans /tmp puis on copie le contenu de files/theses/ +# dans storage/theses/ pour préserver la structure existante. + +unzip xamxam-files-AAAA-MM-JJ.zip -d /tmp/xamxam-restore +cp -r /tmp/xamxam-restore/files/theses/* /var/www/xamxam/storage/theses/ +chown -R www-data:www-data /var/www/xamxam/storage/theses/ +rm -rf /tmp/xamxam-restore +``` + +> **Pourquoi ça fonctionne :** les chemins dans la table `thesis_files` sont +> relatifs (ex. `theses/2025/2025_AUTEUR/fichier.pdf`). En restaurant les +> fichiers au même emplacement sous `storage/`, les liens DB ↔ fichiers sont +> automatiquement rétablis. + +### 3. Redémarrer + +```bash +sudo systemctl start nginx +``` + +### 4. Vérifier + +Naviguer vers `/admin/` et vérifier que la liste des TFE s'affiche +correctement et que les fichiers sont téléchargeables. + +--- + +## Restauration partielle ou re-linkage + +Si la base de données a été perdue mais que vous avez le ZIP des fichiers +et le CSV d'export, vous pouvez reconstruire les liens grâce au +`manifest.json` : + +1. **Réimporter le CSV** via l'interface d'administration (bouton *Importer un + CSV*). Les identifiants (colonne `Identifiant`) doivent correspondre à ceux + du manifeste. + +2. **Décompresser le ZIP** dans `storage/` comme décrit ci-dessus. + +3. **Vérifier avec le manifeste** — les chemins dans `manifest.json` + correspondent aux chemins dans `thesis_files.file_path`. Si les + identifiants du CSV et ceux du manifeste coïncident, les fichiers seront + correctement liés après l'import. + +--- + +## Détails techniques + +### Performance + +L'export des fichiers utilise `ZipArchive` (extension PHP standard) et crée +une archive temporaire dans `/tmp`. Pour un volume important de fichiers +(plusieurs centaines), la création peut prendre quelques secondes. Un timeout +d'exécution raisonnable (`max_execution_time ≥ 60s`) est recommandé. + +### Sécurité + +- Les exports sont **protégés par authentification administrateur** + (`AdminAuth::requireLogin()`). +- L'export DB et l'export fichiers sont journalisés dans le log d'audit + (`admin_audit_log`) avec l'IP et l'User-Agent de l'administrateur. +- Les fichiers temporaires sont supprimés après envoi (`unlink()` dans le + bloc `finally` implicite de `export-files.php`). +- Les fichiers dans le ZIP conservent leurs chemins relatifs d'origine — il + n'y a pas de risque de *path traversal* car les chemins proviennent de la + base de données et ne contiennent jamais `../`. + +### Colonnes du CSV d'export + +Le CSV utilise la même structure que l'import (21 colonnes). Il est +directement ré-importable sans modification. Voir [`docs/import.md`](import.md) +pour le détail des colonnes. + +--- + +## Résumé des commandes utiles + +```bash +# Télécharger les trois exports (depuis le navigateur ou curl) +curl -u admin:password -O https://xamxam.erg.be/admin/actions/export-db.php +curl -u admin:password -O https://xamxam.erg.be/admin/actions/export-csv.php +curl -u admin:password -O https://xamxam.erg.be/admin/actions/export-files.php + +# Restauration complète +sudo systemctl stop nginx +cp xamxam-db-*.sqlite /var/www/xamxam/storage/xamxam.db +unzip xamxam-files-*.zip -d /tmp/xamxam-restore +cp -r /tmp/xamxam-restore/files/theses/* /var/www/xamxam/storage/theses/ +chown -R www-data:www-data /var/www/xamxam/storage/ +sudo systemctl start nginx +```