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

@@ -49,6 +49,8 @@ class ThesisEditController
* - 'currentLanguages' int[]
* - 'currentFormats' int[]
* - 'jury' jury rows
* - 'currentFiles' all thesis_files rows (cover + thesis files)
* - 'currentCover' single thesis_files row for cover, or null
* - 'orientations' lookup rows
* - 'apPrograms' lookup rows
* - 'finalityTypes' lookup rows
@@ -77,6 +79,16 @@ class ThesisEditController
$currentLanguages = $this->db->getThesisLanguageIds($thesisId);
$currentFormats = $this->db->getThesisFormatIds($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();
$apPrograms = $this->db->getAllAPPrograms();
@@ -100,6 +112,8 @@ class ThesisEditController
'currentLanguages' => $currentLanguages,
'currentFormats' => $currentFormats,
'jury' => $jury,
'currentFiles' => $currentFiles,
'currentCover' => $currentCover,
'orientations' => $orientations,
'apPrograms' => $apPrograms,
'finalityTypes' => $finalityTypes,
@@ -230,6 +244,180 @@ class ThesisEditController
} else {
$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 ─────────────────────────────────────────────────────

View File

@@ -1744,6 +1744,95 @@ class Database {
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
// ========================================================================