mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
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:
141
LDAP_SPEC.md
Normal file
141
LDAP_SPEC.md
Normal 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.
|
||||||
8
TODO.md
8
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 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] Update border-radius on pagination buttons to 10px for consistency
|
||||||
- [x] Exclude `storage/maintenance.flag` from rsync deploy and git
|
- [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
|
||||||
|
|||||||
48
app/public/admin/actions/export-files.php
Normal file
48
app/public/admin/actions/export-files.php
Normal 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;
|
||||||
@@ -185,6 +185,15 @@ class AdminLogger
|
|||||||
$this->write('system', 'db_export', 'success');
|
$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 */
|
/** Parametres: delete all TFEs */
|
||||||
public function logDeleteAllTheses(int $count): void
|
public function logDeleteAllTheses(int $count): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -37,6 +37,146 @@ class ExportController
|
|||||||
return $this->db->getDatabasePath();
|
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 ───────────────────────────────────────────────────────
|
// ── CSV export ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,7 +216,7 @@ class ExportController
|
|||||||
*/
|
*/
|
||||||
public function exportAllTheses(): array
|
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();
|
$theses = $this->db->getAllThesesForExport();
|
||||||
if ($theses === []) {
|
if ($theses === []) {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -2188,6 +2188,21 @@ class Database
|
|||||||
')->fetchAll();
|
')->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
|
// SINGLETON PATTERN ENFORCEMENT
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|||||||
@@ -134,6 +134,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
<a href="/admin/actions/export-csv.php" class="btn btn--primary btn--sm">
|
<a href="/admin/actions/export-csv.php" class="btn btn--primary btn--sm">
|
||||||
Exporter CSV
|
Exporter CSV
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin/actions/export-files.php" class="btn btn--primary btn--sm">
|
||||||
|
Exporter fichiers
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
197
docs/export.md
Normal file
197
docs/export.md
Normal 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
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user