feat: fix file deletion on save + trash policy + documents/ prefix + relink browser

1. note_intention: Delete old file only when a genuinely new upload arrives
   (32-char hex file_id), not when the FilePond pool preserves an existing
   file by sending its DB integer ID.  Previously the DB integer ID
   triggered $hasNewNote=true, which deleted the existing note_intention
   from disk+DB, then handleFilePondSingleFile couldn't re-process it
   because the regex requires a hex pattern.  Same fix applied to cover.

2. All file deletions now use deleteThesisFileToTrash() which renames
   files to tmp/_trash/ instead of unlinking.  The trash preserves
   original filenames prefixed with DB id for traceability.  Skips
   website URLs and PeerTube refs (no disk file).

3. Storage prefix changed from theses/ to documents/ to reflect that
   the folder holds all document types (determined by file_type in DB).
   MediaController visibility gate supports both prefixes for backward
   compat with existing files.

4. File browser + relink feature for orphaned files:
   - /admin/fragments/file-browser.php — HTMX tree browser for
     storage/documents/ and storage/theses/
   - /admin/actions/filepond/relink.php — POST endpoint that inserts
     a thesis_files row pointing to existing on-disk file
   - Per-pool "📂 Relier" buttons (edit mode only)
   - JS: XamxamOpenFileBrowser / XamxamRelinkFile with FilePond integration
   - CSS: .relink-modal dialog + .file-browser tree styles
This commit is contained in:
Pontoporeia
2026-05-13 14:58:15 +02:00
parent 6f7a02244f
commit 79eddf5d5a
30 changed files with 191580 additions and 187 deletions

View File

@@ -50,8 +50,8 @@ class MediaController
exit;
}
// 3. Visibility gate for thesis files
if (preg_match('#^theses/#', $requestedPath)) {
// 3. Visibility gate for thesis files (both legacy theses/ and new documents/ paths)
if (preg_match('#^(theses|documents)/#', $requestedPath)) {
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/ErrorHandler.php';
try {

View File

@@ -191,7 +191,7 @@ class ThesisCreateController
$tf = $this->buildThesisFolder($data['annee'], $allAuthorsStr, $data['titre']);
$folderName = $this->ensureUniqueFolder($tf['folderPath']);
// Rebuild path with potentially modified folder name
$folderPath = 'theses/' . $data['annee'] . '/' . $folderName . '/';
$folderPath = 'documents/' . $data['annee'] . '/' . $folderName . '/';
$filePrefix = $folderName;
if (!empty($post['filepond_mode'])) {

View File

@@ -322,11 +322,11 @@ class ThesisEditController
$existingFiles = $this->db->getThesisFiles($thesisId);
foreach ($existingFiles as $f) {
$fp = $f['file_path'] ?? '';
if (str_starts_with($fp, 'theses/')) {
if (str_starts_with($fp, 'documents/') || str_starts_with($fp, 'theses/')) {
$parts = explode('/', $fp);
if (count($parts) >= 3) {
$folderName = $parts[2];
$folderPath = 'theses/' . $year . '/' . $folderName . '/';
$folderPath = 'documents/' . $year . '/' . $folderName . '/';
$filePrefix = $folderName;
break;
}
@@ -341,17 +341,15 @@ class ThesisEditController
// ── Cover image (outside transaction — filesystem op) ─────────────────
if (!empty($post['filepond_mode'])) {
// Async path: cover file_id arrives in post, not $_FILES
if (!empty($post['remove_cover'])) {
// Delete old cover only if a genuinely new cover was uploaded (hex file_id).
// Existing cover preserved in FilePond sends its DB integer ID — skip.
$coverIdRaw = ($post['queue_file']['cover'] ?? null);
$coverId = is_array($coverIdRaw) ? ($coverIdRaw[0] ?? null) : $coverIdRaw;
$isNewCover = $coverId !== null && $coverId !== '' && preg_match('/^[a-f0-9]{32}$/', (string)$coverId);
if ($isNewCover) {
foreach ($existingFiles 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);
}
}
$this->deleteThesisFileToTrash((int)$f['id'], $thesisId);
break;
}
}
@@ -360,13 +358,7 @@ class ThesisEditController
} elseif (!empty($post['remove_cover'])) {
foreach ($existingFiles 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);
}
}
$this->deleteThesisFileToTrash((int)$f['id'], $thesisId);
break;
}
}
@@ -376,19 +368,16 @@ class ThesisEditController
// ── Note d'intention (replace if uploaded) ────────────────────────────
if (!empty($post['filepond_mode'])) {
// Remove old note_intention if new one is uploaded via async path
$newNoteId = ($post['queue_file']['note_intention'] ?? null);
$hasNewNote = $newNoteId !== null && (is_array($newNoteId) ? !empty($newNoteId) : $newNoteId !== '');
if ($hasNewNote) {
// Only delete + replace if a genuinely new file was uploaded (hex file_id).
// Existing files preserved in the FilePond pool send their DB integer ID;
// we must NOT delete them — they're already stored.
$noteIdRaw = ($post['queue_file']['note_intention'] ?? null);
$noteId = is_array($noteIdRaw) ? ($noteIdRaw[0] ?? null) : $noteIdRaw;
$isNewNote = $noteId !== null && $noteId !== '' && preg_match('/^[a-f0-9]{32}$/', (string)$noteId);
if ($isNewNote) {
foreach ($existingFiles as $f) {
if ($f['file_type'] === 'note_intention') {
$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);
}
}
$this->deleteThesisFileToTrash((int)$f['id'], $thesisId);
break;
}
}
@@ -399,13 +388,7 @@ class ThesisEditController
if (!empty($files['note_intention']['tmp_name'] ?? null) && ($files['note_intention']['error'] ?? -1) === UPLOAD_ERR_OK) {
foreach ($existingFiles as $f) {
if ($f['file_type'] === 'note_intention') {
$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);
}
}
$this->deleteThesisFileToTrash((int)$f['id'], $thesisId);
break;
}
}
@@ -421,16 +404,7 @@ class ThesisEditController
if ($fileId <= 0) {
continue;
}
$filePath = $this->db->deleteThesisFile($fileId, $thesisId);
if ($filePath && defined('STORAGE_ROOT')) {
// Skip filesystem deletion for website URLs (not real files)
if (!str_starts_with($filePath, 'http://') && !str_starts_with($filePath, 'https://')) {
$abs = STORAGE_ROOT . '/' . $filePath;
if (file_exists($abs)) {
@unlink($abs);
}
}
}
$this->deleteThesisFileToTrash($fileId, $thesisId);
}
// ── Reorder existing files ────────────────────────────────────────────
@@ -681,7 +655,7 @@ class ThesisEditController
{
$websiteUrl = trim($post['website_url'] ?? '');
// Remove existing website rows
// Remove existing website rows (website URLs have no disk file)
$existingFiles = $this->db->getThesisFiles($thesisId);
foreach ($existingFiles as $f) {
if ($f['file_type'] === 'website') {

View File

@@ -6,7 +6,7 @@
* Shared file-upload logic used by ThesisCreateController and
* ThesisEditController. All on-disk files are stored under:
*
* theses/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/
* documents/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/
*
* Filenames follow:
*
@@ -88,7 +88,7 @@ trait ThesisFileHandler
*
* @param int $thesisId
* @param array|null $upload Single-file $_FILES entry.
* @param string $folderPath Relative path from STORAGE_ROOT to the thesis folder (e.g. "theses/2025/2025_SMITH_Mon_Titre/").
* @param string $folderPath Relative path from STORAGE_ROOT to the thesis folder (e.g. "documents/2025/2025_SMITH_Mon_Titre/").
* @param string $filePrefix The prefix shared by all files in this folder (e.g. "2025_SMITH_Mon_Titre").
*/
protected function handleCoverUpload(int $thesisId, ?array $upload, string $folderPath, string $filePrefix): void
@@ -802,7 +802,7 @@ trait ThesisFileHandler
/**
* Build the folder path and file prefix for a thesis.
*
* Folder: theses/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/
* Folder: documents/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/
* Prefix: {YYYY}_{AUTHORS}_{TITLE_SLUG}
*
* @return array{folderPath: string, filePrefix: string, folderName: string}
@@ -814,7 +814,7 @@ trait ThesisFileHandler
$folderName = $year . '_' . $authorSlug . '_' . $titleSlug;
$filePrefix = $folderName;
$folderPath = 'theses/' . $year . '/' . $folderName . '/';
$folderPath = 'documents/' . $year . '/' . $folderName . '/';
return [
'folderPath' => $folderPath,
@@ -1203,4 +1203,55 @@ trait ThesisFileHandler
}
@rmdir($tmpDir);
}
/**
* Delete a thesis_files row, moving the on-disk file to the trash directory
* instead of unlinking it. Website URLs and PeerTube references are only
* removed from the database (no disk file).
*/
protected function deleteThesisFileToTrash(int $fileId, int $thesisId): void
{
if ($fileId <= 0) {
return;
}
// Fetch the file row to get the path before deleting the DB record
$existingFiles = $this->db->getThesisFiles($thesisId);
$fileRow = null;
foreach ($existingFiles as $f) {
if ((int)$f['id'] === $fileId) {
$fileRow = $f;
break;
}
}
$filePath = $this->db->deleteThesisFile($fileId, $thesisId);
if ($filePath && defined('STORAGE_ROOT')) {
// Skip filesystem for website URLs and PeerTube IDs (not real files)
if (str_starts_with($filePath, 'http://') || str_starts_with($filePath, 'https://')) {
return;
}
if (str_starts_with($filePath, 'peertube_ids:')) {
return;
}
$abs = STORAGE_ROOT . '/' . $filePath;
if (file_exists($abs)) {
$trashDir = STORAGE_ROOT . '/tmp/_trash';
if (!is_dir($trashDir)) {
mkdir($trashDir, 0755, true);
}
// Keep original filename structure for traceability
$trashName = $fileId . '_' . basename($filePath);
$trashPath = $trashDir . '/' . $trashName;
if (!rename($abs, $trashPath)) {
// Fallback to unlink if rename fails (cross-device)
@copy($abs, $trashPath);
@unlink($abs);
}
error_log("ThesisFileHandler: file \$fileId moved to trash → \$trashName");
}
}
}
}

View File

@@ -47,6 +47,12 @@ class Database
} catch (\PDOException $e) {
// Column already exists — ignore
}
// Add 'locked_year' column to share_links if missing
try {
$this->pdo->exec("ALTER TABLE share_links ADD COLUMN locked_year INTEGER");
} catch (\PDOException $e) {
// Column already exists — ignore
}
}
/**
@@ -1013,19 +1019,39 @@ class Database
$email = null;
}
$stmt = $this->pdo->prepare('SELECT id FROM authors WHERE name = ?');
$cleanEmail = ($email !== null && $email !== '') ? $email : null;
// Try to find by name first
$stmt = $this->pdo->prepare('SELECT id, email FROM authors WHERE name = ?');
$stmt->execute([$name]);
$author = $stmt->fetch();
if ($author) {
// Always update email (may be null to clear) and show_contact.
// Update email and show_contact unless that email belongs to another author.
if ($cleanEmail !== null) {
$dup = $this->pdo->prepare('SELECT id FROM authors WHERE email = ? AND id != ?');
$dup->execute([$cleanEmail, $author['id']]);
if ($dup->fetch()) {
$cleanEmail = null; // don't steal another author's email
}
}
$updateStmt = $this->pdo->prepare('UPDATE authors SET email = ?, show_contact = ? WHERE id = ?');
$updateStmt->execute([$email && $email !== '' ? $email : null, $showContact ? 1 : 0, $author['id']]);
$updateStmt->execute([$cleanEmail, $showContact ? 1 : 0, $author['id']]);
return $author['id'];
}
// If an author with this email already exists (different name), reuse that record.
if ($cleanEmail !== null) {
$byEmail = $this->pdo->prepare('SELECT id FROM authors WHERE email = ?');
$byEmail->execute([$cleanEmail]);
$existing = $byEmail->fetch();
if ($existing) {
return $existing['id'];
}
}
$stmt = $this->pdo->prepare('INSERT INTO authors (name, email, show_contact) VALUES (?, ?, ?)');
$stmt->execute([$name, $email, $showContact ? 1 : 0]);
$stmt->execute([$name, $cleanEmail, $showContact ? 1 : 0]);
return $this->pdo->lastInsertId();
}
@@ -1194,6 +1220,67 @@ class Database
// TAG MANAGEMENT (admin)
// ========================================================================
/**
* Search supervisors by name prefix. Returns up to 10 matching supervisors.
* If $query is empty, returns the most-used ones (up to 10).
*
* @param string $role Optional role filter: 'promoteur_interne' (is_external=0, role=promoteur),
* 'promoteur_externe' (is_external=1, role=promoteur),
* 'lecteur_interne' (is_external=0, role=lecteur),
* 'lecteur_externe' (is_external=1, role=lecteur)
*/
public function searchSupervisors(string $query = '', string $role = ''): array
{
$query = trim($query);
// Map role to WHERE conditions
$roleWhere = '';
$roleParams = [];
switch ($role) {
case 'promoteur_interne':
$roleWhere = 'AND ts.is_external = 0 AND ts.role = \'promoteur\'';
break;
case 'promoteur_externe':
$roleWhere = 'AND ts.is_external = 1 AND ts.role = \'promoteur\'';
break;
case 'lecteur_interne':
$roleWhere = 'AND ts.is_external = 0 AND ts.role = \'lecteur\'';
break;
case 'lecteur_externe':
$roleWhere = 'AND ts.is_external = 1 AND ts.role = \'lecteur\'';
break;
default:
break;
}
if ($query === '') {
$stmt = $this->pdo->query('
SELECT s.id, s.name,
COUNT(DISTINCT ts.thesis_id) as thesis_count
FROM supervisors s
LEFT JOIN thesis_supervisors ts ON s.id = ts.supervisor_id
' . $roleWhere . '
GROUP BY s.id
ORDER BY thesis_count DESC, s.name COLLATE NOCASE
LIMIT 10
');
} else {
$stmt = $this->pdo->prepare('
SELECT s.id, s.name,
COUNT(DISTINCT ts.thesis_id) as thesis_count
FROM supervisors s
LEFT JOIN thesis_supervisors ts ON s.id = ts.supervisor_id
WHERE s.name LIKE ?
' . $roleWhere . '
GROUP BY s.id
ORDER BY s.name = ? DESC, thesis_count DESC, s.name COLLATE NOCASE
LIMIT 10
');
$stmt->execute([$query . '%', $query]);
}
return $stmt->fetchAll();
}
/**
* Search tags by name prefix. Returns up to 10 matching tags.
* If $query is empty, returns the most-used tags (up to 10).
@@ -1794,13 +1881,11 @@ class Database
}
// 2. Accent-tolerant fallback: strip accents and re-compare.
// iconv 'ASCII//TRANSLIT' turns é→e, ç→c, etc.
$asciiName = @iconv('UTF-8', 'ASCII//TRANSLIT', $name);
if ($asciiName !== false && $asciiName !== $name) {
$asciiName = self::stripAccents($name);
if ($asciiName !== $name) {
$all = $this->pdo->query('SELECT id, name FROM languages WHERE deleted_at IS NULL')->fetchAll();
foreach ($all as $row) {
$rowAscii = @iconv('UTF-8', 'ASCII//TRANSLIT', strtolower($row['name']));
if ($rowAscii !== false && $rowAscii === $asciiName) {
if (self::stripAccents(strtolower($row['name'])) === $asciiName) {
return (int)$row['id'];
}
}
@@ -1952,7 +2037,7 @@ class Database
}
/**
* Check visibility for a file path under theses/.
* Check visibility for a file path under documents/ or theses/.
* Returns the access_type_id of the owning thesis, or null if the file
* is not found or the path does not belong to a thesis file.
*
@@ -3071,4 +3156,50 @@ class Database
return $result ?: null;
}
/**
* Strip accents from a UTF-8 string (é→e, ç→c, etc.).
* Pure PHP fallback when iconv extension is not available.
*/
private static function stripAccents(string $str): string
{
if (function_exists('iconv')) {
$result = @iconv('UTF-8', 'ASCII//TRANSLIT', $str);
if ($result !== false) {
return strtolower($result);
}
}
// Manual transliteration table for common accented chars
static $map = null;
if ($map === null) {
$utf8 = [
'À','Á','Â','Ã','Ä','Å','Æ','à','á','â','ã','ä','å','æ',
'Ç','ç',
'È','É','Ê','Ë','è','é','ê','ë',
'Ì','Í','Î','Ï','ì','í','î','ï',
'Ð','ð',
'Ñ','ñ',
'Ò','Ó','Ô','Õ','Ö','Ø','ò','ó','ô','õ','ö','ø',
'Ù','Ú','Û','Ü','ù','ú','û','ü',
'Ý','ý','ÿ',
'Š','š','Ž','ž','Þ','þ','Œ','œ','ß',
];
$ascii = [
'A','A','A','A','A','A','AE','a','a','a','a','a','a','ae',
'C','c',
'E','E','E','E','e','e','e','e',
'I','I','I','I','i','i','i','i',
'D','d',
'N','n',
'O','O','O','O','O','O','o','o','o','o','o','o',
'U','U','U','U','u','u','u','u',
'Y','y','y',
'S','s','Z','z','TH','th','OE','oe','ss',
];
$map = array_combine($utf8, $ascii);
}
return strtolower(strtr($str, $map));
}
}

View File

@@ -61,7 +61,7 @@ class ShareLink
* @param string|null $expiresAt ISO-8601 expiration date, null = never expires
* @return array|null The created link row with _plain_password attached
*/
public function create(int $createdBy, ?string $expiresAt = null, ?string $objetRestriction = null, ?string $name = null): ?array
public function create(int $createdBy, ?string $expiresAt = null, ?string $objetRestriction = null, ?string $name = null, ?int $lockedYear = null): ?array
{
$slug = self::generateSlug();
$plainPassword = self::generatePassword();
@@ -74,11 +74,16 @@ class ShareLink
$objetRestriction = 'tfe';
}
// Validate locked_year: must be a plausible academic year (2000..current+3)
if ($lockedYear !== null && ($lockedYear < 2000 || $lockedYear > ((int)date('Y') + 3))) {
$lockedYear = null;
}
$stmt = $this->db->getConnection()->prepare(
'INSERT INTO share_links (slug, name, objet_restriction, password_hash, encrypted_password, is_active, created_by, expires_at)
VALUES (?, ?, ?, ?, ?, 1, ?, ?)'
'INSERT INTO share_links (slug, name, objet_restriction, password_hash, encrypted_password, is_active, created_by, expires_at, locked_year)
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)'
);
$stmt->execute([$slug, $name, $objetRestriction, $passwordHash, Crypto::encrypt($plainPassword), $createdBy, $expiresAt]);
$stmt->execute([$slug, $name, $objetRestriction, $passwordHash, Crypto::encrypt($plainPassword), $createdBy, $expiresAt, $lockedYear]);
$link = $this->findBySlug($slug);
if ($link) {
@@ -235,7 +240,15 @@ class ShareLink
/**
* Update a share link (name, expiration).
*/
public function update(int $id, ?string $name = null, ?string $expiresAt = null): void
/**
* Update a share link's name, expiration, and/or locked year.
*
* $lockedYear:
* - null → leave unchanged (not present in POST)
* - "" → clear (set to NULL in DB)
* - non-empty string → parse as int, validate, and set
*/
public function update(int $id, ?string $name = null, ?string $expiresAt = null, mixed $lockedYear = null): void
{
$pdo = $this->db->getConnection();
$fields = [];
@@ -250,6 +263,17 @@ class ShareLink
$fields[] = 'expires_at = ?';
$params[] = $expiresAtVal;
}
if ($lockedYear !== null) {
if ($lockedYear === '' || $lockedYear === false) {
$fields[] = 'locked_year = NULL';
} else {
$year = filter_var($lockedYear, FILTER_VALIDATE_INT);
if ($year !== false && $year >= 2000 && $year <= ((int)date('Y') + 3)) {
$fields[] = 'locked_year = ?';
$params[] = $year;
}
}
}
if (!empty($fields)) {
$params[] = $id;