admin edit.php: add cover image + thesis file management fields

- Database: add deleteThesisFile() and handleCoverUpload() methods
- ThesisEditController::load(): expose currentFiles + currentCover to view
- ThesisEditController::save(): handle couverture upload/removal,
  per-file deletion (delete_files[]), and new thesis file uploads
- edit.php template: new Fichiers fieldset with cover preview+remove,
  existing files list with delete checkboxes, new file upload input
  (mirrors add.php / partage.php)
This commit is contained in:
Pontoporeia
2026-04-27 20:33:21 +02:00
parent 27e1b6828d
commit 8e864fc624
4 changed files with 340 additions and 0 deletions

View File

@@ -1,5 +1,14 @@
# TFE Access Restriction Feature # TFE Access Restriction Feature
## Admin Edit Form — File Management
- [x] Add cover image upload/preview/remove to edit.php
- [x] Add existing thesis files listing with per-file delete checkboxes
- [x] Add new thesis files upload field (PDF, JPG, PNG, MP4, ZIP, VTT)
- [x] Add `deleteThesisFile()` and `handleCoverUpload()` to Database.php
- [x] Update `ThesisEditController::save()` to handle cover, file deletion, new uploads
- [x] Update `ThesisEditController::load()` to expose `currentFiles` + `currentCover`
## Overview ## Overview
Add access restriction for TFE attached files based on user email domain, with admin validation workflow. Add access restriction for TFE attached files based on user email domain, with admin validation workflow.

View File

@@ -49,6 +49,8 @@ class ThesisEditController
* - 'currentLanguages' int[] * - 'currentLanguages' int[]
* - 'currentFormats' int[] * - 'currentFormats' int[]
* - 'jury' jury rows * - 'jury' jury rows
* - 'currentFiles' all thesis_files rows (cover + thesis files)
* - 'currentCover' single thesis_files row for cover, or null
* - 'orientations' lookup rows * - 'orientations' lookup rows
* - 'apPrograms' lookup rows * - 'apPrograms' lookup rows
* - 'finalityTypes' lookup rows * - 'finalityTypes' lookup rows
@@ -77,6 +79,16 @@ class ThesisEditController
$currentLanguages = $this->db->getThesisLanguageIds($thesisId); $currentLanguages = $this->db->getThesisLanguageIds($thesisId);
$currentFormats = $this->db->getThesisFormatIds($thesisId); $currentFormats = $this->db->getThesisFormatIds($thesisId);
$jury = $this->db->getThesisJury($thesisId); $jury = $this->db->getThesisJury($thesisId);
$currentFiles = $this->db->getThesisFiles($thesisId);
// Separate out the cover entry for convenience
$currentCover = null;
foreach ($currentFiles as $f) {
if ($f['file_type'] === 'cover') {
$currentCover = $f;
break;
}
}
$orientations = $this->db->getAllOrientations(); $orientations = $this->db->getAllOrientations();
$apPrograms = $this->db->getAllAPPrograms(); $apPrograms = $this->db->getAllAPPrograms();
@@ -100,6 +112,8 @@ class ThesisEditController
'currentLanguages' => $currentLanguages, 'currentLanguages' => $currentLanguages,
'currentFormats' => $currentFormats, 'currentFormats' => $currentFormats,
'jury' => $jury, 'jury' => $jury,
'currentFiles' => $currentFiles,
'currentCover' => $currentCover,
'orientations' => $orientations, 'orientations' => $orientations,
'apPrograms' => $apPrograms, 'apPrograms' => $apPrograms,
'finalityTypes' => $finalityTypes, 'finalityTypes' => $finalityTypes,
@@ -230,6 +244,180 @@ class ThesisEditController
} else { } else {
$this->db->handleBannerUpload($thesisId, $files['banner'] ?? null); $this->db->handleBannerUpload($thesisId, $files['banner'] ?? null);
} }
// ── Cover image (outside transaction — filesystem op) ─────────────────
if (isset($post['remove_cover'])) {
$allFiles = $this->db->getThesisFiles($thesisId);
foreach ($allFiles as $f) {
if ($f['file_type'] === 'cover') {
$this->db->deleteThesisFile((int)$f['id'], $thesisId);
if (!empty($f['file_path']) && defined('STORAGE_ROOT')) {
$abs = STORAGE_ROOT . '/' . $f['file_path'];
if (file_exists($abs)) @unlink($abs);
}
break;
}
}
} else {
$this->db->handleCoverUpload($thesisId, $files['couverture'] ?? null);
}
// ── Delete individual thesis files ────────────────────────────────────
$deleteIds = isset($post['delete_files']) && is_array($post['delete_files'])
? array_map('intval', $post['delete_files'])
: [];
foreach ($deleteIds as $fileId) {
if ($fileId <= 0) continue;
$filePath = $this->db->deleteThesisFile($fileId, $thesisId);
if ($filePath && defined('STORAGE_ROOT')) {
$abs = STORAGE_ROOT . '/' . $filePath;
if (file_exists($abs)) @unlink($abs);
}
}
// ── New thesis files upload ───────────────────────────────────────────
if (!empty($files['files']['name'][0])) {
$this->handleThesisFiles($thesisId, $post, $files['files']);
}
}
// ── Private: file uploads ─────────────────────────────────────────────────
/**
* Process multiple new thesis-file uploads.
*
* Files are stored in the existing folder used by this thesis (detected
* from any current thesis_files row), or a new one is created following
* the same {year}_{authorSlug} convention as ThesisCreateController.
*/
private function handleThesisFiles(int $thesisId, array $post, array $uploads): void
{
$allowedMimes = [
'image/jpeg', 'image/png', 'application/pdf',
'video/mp4', 'application/zip', 'text/vtt',
];
$allowedExts = ['jpg', 'jpeg', 'png', 'pdf', 'mp4', 'zip', 'vtt'];
$maxBytes = 50 * 1024 * 1024; // 50 MB
$year = (int)($post['année'] ?? date('Y'));
$authorName = trim($post['auteurice'] ?? 'unknown');
$authorSlug = $this->generateAuthorSlug($authorName);
// Reuse existing folder if possible
$existingFiles = $this->db->getThesisFiles($thesisId);
$uploadDir = null;
$folderName = null;
foreach ($existingFiles as $f) {
if (str_starts_with($f['file_path'] ?? '', 'theses/')) {
$parts = explode('/', $f['file_path']);
if (count($parts) >= 3) {
$folderName = $parts[2];
$uploadDir = STORAGE_ROOT . "/theses/{$year}/{$folderName}/";
break;
}
}
}
if ($uploadDir === null) {
$folderName = $this->ensureUniqueFolder($year, $authorSlug);
$uploadDir = STORAGE_ROOT . "/theses/{$year}/{$folderName}/";
}
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
$count = count($uploads['name']);
for ($i = 0; $i < $count; $i++) {
if (($uploads['error'][$i] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) continue;
if (($uploads['error'][$i] ?? -1) !== UPLOAD_ERR_OK) {
error_log("ThesisEditController: upload error {$uploads['error'][$i]} for {$uploads['name'][$i]}");
continue;
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($uploads['tmp_name'][$i]);
$ext = strtolower(pathinfo($uploads['name'][$i], PATHINFO_EXTENSION));
if ($mimeType === 'text/plain' && $ext === 'vtt') {
$mimeType = 'text/vtt';
}
if (!in_array($mimeType, $allowedMimes, true) || !in_array($ext, $allowedExts, true)) {
error_log("ThesisEditController: invalid type {$uploads['name'][$i]} ($mimeType), skipping");
continue;
}
if ($uploads['size'][$i] > $maxBytes) {
error_log("ThesisEditController: file too large {$uploads['name'][$i]}, skipping");
continue;
}
$originalName = $uploads['name'][$i];
$sanitized = $this->sanitizeFilename($originalName);
$prefix = $authorSlug . '_' . $sanitized;
$candidate = $prefix;
$suffix = 1;
while (file_exists($uploadDir . $candidate)) {
$candidate = $authorSlug . '_' . pathinfo($sanitized, PATHINFO_FILENAME) . '_' . $suffix . '.' . $ext;
$suffix++;
}
$targetPath = $uploadDir . $candidate;
if (!move_uploaded_file($uploads['tmp_name'][$i], $targetPath)) {
error_log("ThesisEditController: failed to move {$originalName}");
continue;
}
chmod($targetPath, 0644);
$fileType = 'other';
if ($ext === 'vtt') $fileType = 'caption';
elseif (stripos($originalName, 'annex') !== false) $fileType = 'annex';
elseif ($ext === 'pdf') $fileType = 'main';
$relPath = "theses/{$year}/{$folderName}/" . $candidate;
$this->db->insertThesisFile($thesisId, $fileType, $relPath, basename($originalName), $uploads['size'][$i], $mimeType);
error_log("ThesisEditController: uploaded → $candidate ($fileType)");
}
}
// ── Private: string helpers ───────────────────────────────────────────────
private function generateAuthorSlug(string $authorName): string
{
$n = function_exists('iconv') ? iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $authorName) : $authorName;
$accents = [
'à'=>'a','â'=>'a','ä'=>'a','é'=>'e','è'=>'e','ê'=>'e','ë'=>'e',
'î'=>'i','ï'=>'i','ô'=>'o','ö'=>'o','ù'=>'u','û'=>'u','ü'=>'u','ç'=>'c',
];
$n = strtr($n, $accents);
$slug = strtoupper(trim(preg_replace('/[^A-Za-z0-9]+/', '_', $n), '_'));
return $slug !== '' ? $slug : 'AUTHOR';
}
private function sanitizeFilename(string $filename): string
{
$ext = pathinfo($filename, PATHINFO_EXTENSION);
$name = pathinfo($filename, PATHINFO_FILENAME);
$n = function_exists('iconv') ? iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $name) : $name;
$accents = [
'à'=>'a','â'=>'a','ä'=>'a','é'=>'e','è'=>'e','ê'=>'e','ë'=>'e',
'î'=>'i','ï'=>'i','ô'=>'o','ö'=>'o','ù'=>'u','û'=>'u','ü'=>'u','ç'=>'c',
];
$n = trim(preg_replace('/[^A-Za-z0-9]+/', '_', strtr($n, $accents)), '_');
if ($n === '') $n = 'file';
return $ext !== '' ? $n . '.' . strtolower($ext) : $n;
}
private function ensureUniqueFolder(int $year, string $authorSlug): string
{
$baseDir = STORAGE_ROOT . '/theses/' . $year . '/';
$candidate = $year . '_' . $authorSlug;
$suffix = 1;
while (is_dir($baseDir . $candidate)) {
$candidate = $year . '_' . $authorSlug . '_' . $suffix++;
}
return $candidate;
} }
// ── WCAG 3.3.1 helper ───────────────────────────────────────────────────── // ── WCAG 3.3.1 helper ─────────────────────────────────────────────────────

View File

@@ -1744,6 +1744,95 @@ class Database {
return $this->pdo->lastInsertId(); return $this->pdo->lastInsertId();
} }
/**
* Delete a single thesis file record by its ID and optionally remove the
* file from disk. Returns the file_path that was deleted (or null if not
* found), so the caller can clean up the filesystem.
*
* @param int $fileId Primary key of thesis_files row.
* @param int $thesisId Owning thesis ID (used as a safety guard).
* @return string|null The file_path that was stored, or null.
*/
public function deleteThesisFile(int $fileId, int $thesisId): ?string
{
$stmt = $this->pdo->prepare(
"SELECT file_path FROM thesis_files WHERE id = ? AND thesis_id = ? LIMIT 1"
);
$stmt->execute([$fileId, $thesisId]);
$row = $stmt->fetch();
if (!$row) {
return null;
}
$this->pdo->prepare("DELETE FROM thesis_files WHERE id = ?")->execute([$fileId]);
return $row['file_path'];
}
/**
* Replace the cover image for a thesis: removes any existing cover record
* (and its file from disk), then inserts the new one.
*
* @param int $thesisId
* @param array|null $upload Single-file $_FILES entry.
* @return string|null Relative path of the new cover, or null.
*/
public function handleCoverUpload(int $thesisId, ?array $upload): ?string
{
if (!$upload || ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
return null;
}
$allowedMimes = ['image/jpeg', 'image/png'];
$allowedExts = ['jpg', 'jpeg', 'png'];
$maxBytes = 10 * 1024 * 1024; // 10 MB
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($upload['tmp_name']);
$ext = strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION));
if (!in_array($mimeType, $allowedMimes, true)
|| !in_array($ext, $allowedExts, true)
|| $upload['size'] > $maxBytes) {
error_log("handleCoverUpload: rejected {$upload['name']} ($mimeType, {$upload['size']} bytes)");
return null;
}
// Remove existing cover record + file
$existing = $this->pdo->prepare(
"SELECT id, file_path FROM thesis_files WHERE thesis_id = ? AND file_type = 'cover' LIMIT 1"
);
$existing->execute([$thesisId]);
if ($old = $existing->fetch()) {
$this->pdo->prepare("DELETE FROM thesis_files WHERE id = ?")->execute([$old['id']]);
if (!empty($old['file_path']) && defined('STORAGE_ROOT')) {
$abs = STORAGE_ROOT . '/' . $old['file_path'];
if (file_exists($abs)) @unlink($abs);
}
}
$coverDir = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/covers/' : null;
if (!$coverDir) {
error_log('handleCoverUpload: STORAGE_ROOT not defined');
return null;
}
if (!is_dir($coverDir)) {
mkdir($coverDir, 0755, true);
}
$safeName = bin2hex(random_bytes(16)) . '.' . $ext;
$targetPath = $coverDir . $safeName;
if (!move_uploaded_file($upload['tmp_name'], $targetPath)) {
error_log("handleCoverUpload: move_uploaded_file failed for {$upload['name']}");
return null;
}
chmod($targetPath, 0644);
$relPath = 'covers/' . $safeName;
$this->insertThesisFile($thesisId, 'cover', $relPath, basename($upload['name']), $upload['size'], $mimeType);
error_log("handleCoverUpload: saved $relPath");
return $relPath;
}
// ======================================================================== // ========================================================================
// EXPORT HELPERS — used by ExportController // EXPORT HELPERS — used by ExportController
// ======================================================================== // ========================================================================

View File

@@ -95,6 +95,60 @@
<?php $name = 'lien'; $label = 'Lien externe :'; $value = htmlspecialchars($thesis['baiu_link'] ?? ''); $type = 'url'; include APP_ROOT . '/templates/partials/form/text-field.php'; ?> <?php $name = 'lien'; $label = 'Lien externe :'; $value = htmlspecialchars($thesis['baiu_link'] ?? ''); $type = 'url'; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<!-- Fichiers -->
<fieldset>
<legend>Fichiers</legend>
<!-- Cover image -->
<div>
<label>Image de couverture :</label>
<div>
<?php if (!empty($currentCover)): ?>
<div class="admin-banner-preview">
<img src="/media.php?path=<?= urlencode($currentCover['file_path']) ?>"
alt="Couverture actuelle" style="max-height:180px;">
<label class="admin-checkbox-label">
<input type="checkbox" name="remove_cover" value="1"> Supprimer la couverture
</label>
</div>
<?php endif; ?>
<?php $name = 'couverture'; $label = empty($currentCover) ? 'Image de couverture :' : 'Remplacer la couverture :'; $accept = 'image/jpeg,image/png'; $hint = 'JPG, PNG. Max 10 MB.'; include APP_ROOT . '/templates/partials/form/file-field.php'; ?>
</div>
</div>
<!-- Existing thesis files -->
<?php
$thesisFilesList = array_filter($currentFiles, fn($f) => $f['file_type'] !== 'cover');
?>
<?php if (!empty($thesisFilesList)): ?>
<div class="admin-form-group">
<label>Fichiers existants :</label>
<ul class="admin-file-list">
<?php foreach ($thesisFilesList as $f): ?>
<li class="admin-file-list-item">
<span class="admin-file-info">
<span class="admin-file-type">[<?= htmlspecialchars($f['file_type']) ?>]</span>
<a href="/media.php?path=<?= urlencode($f['file_path']) ?>" target="_blank" rel="noopener">
<?= htmlspecialchars($f['file_name'] ?? basename($f['file_path'])) ?>
</a>
<?php if (!empty($f['file_size'])): ?>
<small>(<?= number_format($f['file_size'] / 1024 / 1024, 2) ?> MB)</small>
<?php endif; ?>
</span>
<label class="admin-checkbox-label admin-file-delete">
<input type="checkbox" name="delete_files[]" value="<?= (int)$f['id'] ?>">
Supprimer
</label>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<!-- New thesis files -->
<?php $name = 'files'; $label = 'Ajouter des fichiers :'; $accept = '.pdf,.jpg,.jpeg,.png,.mp4,.zip,.vtt'; $hint = 'PDF, JPG, PNG, MP4, ZIP. Max 50 MB par fichier. Pour les vidéos, un fichier .vtt peut être joint.'; $multiple = true; include APP_ROOT . '/templates/partials/form/file-field.php'; ?>
</fieldset>
<!-- Image bannière (custom: includes current banner preview + remove checkbox) --> <!-- Image bannière (custom: includes current banner preview + remove checkbox) -->
<div> <div>
<label>Image bannière (accueil) :</label> <label>Image bannière (accueil) :</label>