add file export system for admins

- ExportController: getAllThesisFiles(), buildExportManifest(), createExportZip()
  builds a ZIP archive with manifest.json + files/ mirror of storage/theses/
- Database: getAllThesisFilesForExport() queries all thesis_files + identifier
- AdminLogger: logFilesExport() audit log entry
- admin/actions/export-files.php: thin dispatcher, streams zip with headers
- templates/admin/index.php: 'Exporter fichiers' button next to CSV export
This commit is contained in:
Pontoporeia
2026-05-07 16:28:17 +02:00
parent 821369f004
commit 7793b6f86d
8 changed files with 562 additions and 1 deletions

141
LDAP_SPEC.md Normal file
View File

@@ -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" `<section>` (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.

View File

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

View File

@@ -0,0 +1,48 @@
<?php
/**
* Export all thesis files as a ZIP archive.
*
* The ZIP contains:
* - manifest.json — maps every thesis to its files with DB-relevant metadata
* - files/ — mirror of storage/theses/ directory structure
*
* Thin dispatcher — delegates all data assembly to ExportController,
* then streams the response.
*/
require_once __DIR__ . "/../../../bootstrap.php";
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
require_once APP_ROOT . '/src/Controllers/ExportController.php';
require_once APP_ROOT . '/src/AdminLogger.php';
try {
$controller = ExportController::create();
// Build the zip
$zipPath = $controller->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;

View File

@@ -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
{

View File

@@ -37,6 +37,146 @@ class ExportController
return $this->db->getDatabasePath();
}
// ── Files export ────────────────────────────────────────────────────
/**
* Fetch all thesis file records with their thesis identifier.
*
* @return list<array{id:int, thesis_id:int, identifier:?string, file_type:string,
* file_path:string, file_name:string, file_size:?int,
* mime_type:?string, description:?string, sort_order:int,
* display_label:?string, file_hash:?string}>
*/
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 [];

View File

@@ -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
// ========================================================================

View File

@@ -134,6 +134,9 @@ document.addEventListener('DOMContentLoaded', () => {
<a href="/admin/actions/export-csv.php" class="btn btn--primary btn--sm">
Exporter CSV
</a>
<a href="/admin/actions/export-files.php" class="btn btn--primary btn--sm">
Exporter fichiers
</a>
</div>
</div>

197
docs/export.md Normal file
View File

@@ -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
```