fix: prevent file deletion on relink + restore button now visible + OOB-style in-place update

Two critical fixes:

1. Relink flow no longer destroys/recreates FilePond instances:
   The relink (XamxamRelinkFile) and PeerTube relink (XamxamRelinkPeerTube)
   previously refreshed the entire fichiers fragment via HTMX after
   pond.addFile(). This triggered destroyFilePondsIn on ALL pools, which
   could fire server.remove callbacks and move existing files to corbeille.
   Now just closes the modal — the file is already added to the pool in-place,
   and syncOrderInput creates the hidden form input.

2. Cleanup page « Corbeille (restaurable) » now actually shows files:
   _cleanup-stats-data.php previously classified trash files by checking if
   the thesis_files DB row still existed. But both deleteThesisFileToTrash
   and FilepondHandler::handleRemove DELETE the DB row. So ALL trash files
   appeared as `stale` (not restorable). Now uses the JSON sidecar file
   presence as the classification criterion — if the sidecar exists and is
   recent, the file is restorable regardless of DB row state.

Also removed unused DB query from _cleanup-stats-data.php.
This commit is contained in:
Pontoporeia
2026-07-10 16:29:04 +02:00
parent ed19e30cf0
commit 3cecee10c9
12 changed files with 587 additions and 73 deletions
@@ -120,17 +120,12 @@ function getCleanupStats(): array
$db = new Database();
$pdo = $db->getPDO();
$existingFileIds = [];
$stmt = $pdo->query('SELECT id FROM thesis_files');
while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
$existingFileIds[(int)$row['id']] = true;
}
$trStaleCount = 0;
$trStaleSize = 0;
$trStaleFiles = [];
$trActiveCount = 0;
$trActiveSize = 0;
$trActiveFiles = [];
if (is_dir($trashDir)) {
$items = @scandir($trashDir);
@@ -139,6 +134,10 @@ function getCleanupStats(): array
if ($item === '.' || $item === '..') {
continue;
}
// Skip JSON sidecar files — they're listed alongside their parent.
if (str_ends_with($item, '.json')) {
continue;
}
$filePath = $trashDir . '/' . $item;
if (!is_file($filePath)) {
continue;
@@ -148,20 +147,37 @@ function getCleanupStats(): array
$mtime = filemtime($filePath);
$ageDays = (int)(($now - $mtime) / 86400);
$stale = false;
if (preg_match('/^(\d+)_/', $item, $m)) {
$dbId = (int)$m[1];
if (!isset($existingFileIds[$dbId])) {
$stale = true;
// Check for sidecar metadata — files with sidecars are restorable
// regardless of whether the thesis_files DB row still exists.
$sidecarPath = $filePath . '.json';
$hasSidecar = file_exists($sidecarPath);
$sidecarData = null;
if ($hasSidecar) {
$sidecarData = json_decode(file_get_contents($sidecarPath), true);
if (!is_array($sidecarData)) {
$hasSidecar = false;
$sidecarData = null;
}
}
if (!$stale && $ageDays > ($maxAgeTrash / 86400)) {
$stale = true;
}
// Restorable = has a valid sidecar AND is younger than max age
$restorable = $hasSidecar && $ageDays <= ($maxAgeTrash / 86400);
if ($stale) {
if ($restorable) {
$trActiveCount++;
$trActiveSize += $size;
$trActiveFiles[] = [
'name' => $item,
'size' => $size,
'human' => humanBytes($size),
'age_days' => $ageDays,
'has_sidecar' => true,
'thesis_id' => $sidecarData['thesis_id'] ?? null,
'file_type' => $sidecarData['file_type'] ?? '?',
'original_name' => $sidecarData['file_name'] ?? '',
'original_path' => $sidecarData['file_path'] ?? '',
];
} else {
$trStaleCount++;
$trStaleSize += $size;
$trStaleFiles[] = [
@@ -170,9 +186,6 @@ function getCleanupStats(): array
'human' => humanBytes($size),
'age_days' => $ageDays,
];
} else {
$trActiveCount++;
$trActiveSize += $size;
}
}
}
@@ -193,5 +206,6 @@ function getCleanupStats(): array
'trash_active_count' => $trActiveCount,
'trash_active_size' => $trActiveSize,
'trash_active_human' => humanBytes($trActiveSize),
'trash_active_files' => $trActiveFiles,
];
}
@@ -92,7 +92,7 @@ if ($trStale > 0) {
<?php endif; ?>
<?php if ($trStale > 0): ?>
<h3 id="tmp-trash-heading">Corbeille <span class="n-meta"><?= htmlspecialchars($trMeta) ?></span></h3>
<h3 id="tmp-trash-heading">Corbeille (à nettoyer) <span class="n-meta"><?= htmlspecialchars($trMeta) ?></span></h3>
<table class="n-table" aria-labelledby="tmp-trash-heading">
<thead><tr><th width="1%"><input type="checkbox" onchange="cleanupToggleAll(this, 'trash')" title="Tout sélectionner"></th><th>Nom</th><th>Taille</th><th>Âge</th><th width="1%"></th></tr></thead>
<tbody>
@@ -120,9 +120,62 @@ if ($trStale > 0) {
</table>
<?php endif; ?>
<?php if ($fpActive > 0 || $trActive > 0): ?>
<?php
$trActiveFiles = $d['trash_active_files'] ?? [];
if (!empty($trActiveFiles)):
$trActiveMeta = $trActive . ' fichier' . ($trActive > 1 ? 's' : '');
if ($trActive > 0) {
$trActiveMeta .= ' · ' . ($d['trash_active_human'] ?? '');
}
?>
<h3 id="tmp-trash-restore-heading">Corbeille (restaurable) <span class="n-meta"><?= htmlspecialchars($trActiveMeta) ?></span></h3>
<p style="font-size:0.85em;color:var(--text-secondary);margin:0 0 var(--space-sm)">
Fichiers récemment supprimés pour lesquels le TFE associé existe encore. Vous pouvez les restaurer ou les supprimer définitivement.
</p>
<table class="n-table" aria-labelledby="tmp-trash-restore-heading">
<thead><tr><th>Nom</th><th>Taille</th><th>Âge</th><th>Origine</th><th width="1%"></th></tr></thead>
<tbody>
<?php foreach ($trActiveFiles as $f): ?>
<tr>
<td><strong><?= htmlspecialchars($f['name']) ?></strong></td>
<td style="white-space:nowrap"><?= htmlspecialchars($f['human']) ?></td>
<td style="white-space:nowrap">~<?= (int)$f['age_days'] ?> j</td>
<td style="font-size:0.85em;color:var(--text-secondary)">
<?= $f['has_sidecar'] ? htmlspecialchars($f['original_name'] ?? '?') . ' (' . htmlspecialchars($f['file_type'] ?? '?') . ')' : 'métadonnées indisponibles' ?>
</td>
<td style="white-space:nowrap">
<?php if ($f['has_sidecar']): ?>
<button type="button" class="btn btn--sm"
style="font-size:0.85em;padding:2px var(--space-xs);margin-right:4px"
hx-post="/admin/actions/restore-trash.php"
hx-confirm="Restaurer ce fichier vers le TFE associé ?"
hx-vals='{"csrf_token":"<?= htmlspecialchars($_SESSION['csrf_token']) ?>","trash_file":"<?= htmlspecialchars($f['name']) ?>"}'
hx-target="#tmp-cleanup-stats-wrapper"
hx-swap="innerHTML"
hx-indicator="#tmp-cleanup-stats-wrapper">
↺ Restaurer
</button>
<?php endif; ?>
<button type="button" class="btn btn--sm btn--danger"
style="font-size:0.85em;padding:2px var(--space-xs)"
hx-post="/admin/actions/cleanup-tmp.php"
hx-confirm="Supprimer définitivement ce fichier de la corbeille ?"
hx-vals='{"csrf_token":"<?= htmlspecialchars($_SESSION['csrf_token']) ?>","trash_file":"<?= htmlspecialchars($f['name']) ?>"}'
hx-target="#tmp-cleanup-stats-wrapper"
hx-swap="innerHTML"
hx-indicator="#tmp-cleanup-stats-wrapper">
<?= icon('trash') ?>
Supprimer
</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php if ($fpActive > 0): ?>
<p style="margin:var(--space-sm) 0 0 0;font-size:0.85em;color:var(--text-secondary)">Conservés :
<?php if ($fpActive) echo $fpActive . ' téléversement(s) actif(s) (' . htmlspecialchars($d['filepond_active_human']) . '), '; ?>
<?php if ($trActive) echo $trActive . ' fichier(s) récent(s) (' . htmlspecialchars($d['trash_active_human']) . ')'; ?>
<?= $fpActive . ' téléversement(s) actif(s) (' . htmlspecialchars($d['filepond_active_human']) . ')' ?>
</p>
<?php endif; ?>
+157
View File
@@ -0,0 +1,157 @@
<?php
/**
* Restore a trashed file back to its original location (admin).
*
* POST /admin/actions/restore-trash.php
* Body: trash_file=123_filename.pdf
*
* Reads the JSON sidecar file (trash_file.json) to get the original
* thesis_files row metadata, re-inserts the DB row, and moves the file
* back from tmp/_trash to its original storage path.
*/
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => 'Méthode non autorisée.']);
exit;
}
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
http_response_code(403);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => 'CSRF invalide.']);
exit;
}
$trashFile = trim($_POST['trash_file'] ?? '');
if ($trashFile === '') {
http_response_code(400);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => 'Nom de fichier invalide.']);
exit;
}
$storageRoot = STORAGE_ROOT;
$trashDir = $storageRoot . '/tmp/_trash';
$trashPath = $trashDir . '/' . basename($trashFile);
$sidecarPath = $trashPath . '.json';
// Validate the trash file is inside the trash directory
if (!is_file($trashPath) || !str_starts_with(realpath($trashPath), realpath($trashDir))) {
http_response_code(404);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => 'Fichier introuvable dans la corbeille.']);
exit;
}
// Read sidecar metadata
if (!file_exists($sidecarPath)) {
http_response_code(400);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => false,
'error' => 'Métadonnées de restauration absentes (fichier trop ancien ?). Utilisez "Relier un fichier existant".',
]);
exit;
}
$sidecar = json_decode(file_get_contents($sidecarPath), true);
if (!is_array($sidecar) || empty($sidecar['file_path']) || empty($sidecar['thesis_id'])) {
http_response_code(400);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => 'Métadonnées corrompues.']);
exit;
}
$originalPath = $sidecar['file_path'];
$thesisId = (int)$sidecar['thesis_id'];
$fileType = $sidecar['file_type'] ?? 'other';
$fileName = $sidecar['file_name'] ?? basename($originalPath);
$mimeType = $sidecar['mime_type'] ?? 'application/octet-stream';
$fileSize = (int)($sidecar['file_size'] ?? 0);
$displayLabel = $sidecar['display_label'] ?? null;
// Verify the thesis still exists
require_once __DIR__ . '/../../../src/Database.php';
$db = new Database();
$thesis = $db->getThesis($thesisId);
if (!$thesis) {
http_response_code(404);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => 'Le TFE associé n\'existe plus.']);
exit;
}
// Check no file already exists at the original path
$absOriginal = $storageRoot . '/' . $originalPath;
if (file_exists($absOriginal)) {
// File already restored or a new file replaced it — just clean up the trash
@unlink($trashPath);
@unlink($sidecarPath);
http_response_code(409);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => false,
'error' => 'Un fichier existe déjà à l\'emplacement d\'origine. Fichier corbeille nettoyé.',
]);
exit;
}
// Ensure parent directory exists
$parentDir = dirname($absOriginal);
if (!is_dir($parentDir)) {
mkdir($parentDir, 0755, true);
}
// Move file back from trash to original location
if (!rename($trashPath, $absOriginal)) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => 'Échec du déplacement du fichier.']);
exit;
}
chmod($absOriginal, 0644);
// Delete the sidecar file
@unlink($sidecarPath);
// Re-insert the thesis_files DB row
$db->insertThesisFile(
$thesisId,
$fileType,
$originalPath,
$fileName,
$fileSize,
$mimeType,
$displayLabel,
null
);
$newId = $db->getConnection()->lastInsertId();
error_log("[restore-trash] thesis_id=$thesisId file_id=$newId restored from trash: $trashFile$originalPath");
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
// HTMX request: re-render the fragment
if (isset($_SERVER['HTTP_HX_REQUEST'])) {
header('HX-Trigger: refreshStats');
require __DIR__ . '/cleanup-stats-fragment.php';
exit;
}
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => true,
'id' => $newId,
'thesis_id' => $thesisId,
'message' => 'Fichier restauré avec succès.',
]);
exit;
@@ -321,6 +321,15 @@
remove: (source, load, error) => {
console.log(`[filepond] remove called | id=${source}`);
// During teardown (HTMX swap), skip server round-trips.
// FilePond 4.x destroy() does not normally fire remove callbacks,
// but this guard prevents accidental deletion of DB files as a
// defence-in-depth measure.
if (_xamxamTeardown) {
console.log(`[filepond] remove skipped (teardown) | id=${source}`);
load();
return;
}
// Hex IDs (32 chars) → temp files → use revert endpoint
if (/^[a-f0-9]{32}$/.test(source)) {
fetch(`${base}/revert.php`, {
@@ -519,6 +528,13 @@
});
};
/**
* Guard flag set during destroyFilePondsIn — the server.remove callback
* checks this and skips the server round-trip during teardown to prevent
* accidental deletion of existing DB files from FilePond.destroy().
*/
let _xamxamTeardown = false;
/**
* Destroy FilePond instances inside a given container element.
* Generic: handles ANY HTMX swap target, not just known IDs.
@@ -563,8 +579,12 @@
} catch (_abort) {}
}
}
_xamxamTeardown = true;
pond.destroy();
} catch (_) {}
_xamxamTeardown = false;
} catch (_) {
_xamxamTeardown = false;
}
}
});
}
@@ -885,24 +905,9 @@
" | found=" +
!!input,
);
var closeAndRefresh = () => {
var closeModal = () => {
var modal = document.getElementById("relink-modal");
if (modal) modal.close();
// Re-fetch the fichiers fragment from the server so the
// newly-linked file appears in the FilePond pools.
var block = document.getElementById("format-fichiers-block");
if (block && window.htmx) {
let url = "/admin/fragments/fichiers.php";
if (window.__xamxamRelinkCtx?.thesisId) {
url +=
"?_thesis_id=" +
encodeURIComponent(window.__xamxamRelinkCtx.thesisId);
}
htmx.ajax("GET", url, {
target: "#format-fichiers-block",
swap: "outerHTML",
});
}
};
if (input) {
const pond = FilePond.find(input);
@@ -924,25 +929,25 @@
" | queueType=" +
queueType,
);
closeAndRefresh();
closeModal();
})
.catch((err) => {
console.error("[relink] addFile rejected", err);
closeAndRefresh();
closeModal();
});
} else {
console.error(
"[relink] FilePond.find returned null for input",
input,
);
closeAndRefresh();
closeModal();
}
} else {
console.warn(
"[relink] input not found, page may have reloaded | queueType=" +
queueType,
);
closeAndRefresh();
closeModal();
}
// Mark form dirty
@@ -1008,18 +1013,9 @@
var input = document.querySelector(
'.tfe-file-picker[data-queue-type="tfe"]',
);
var closeAndRefresh = () => {
var closeModal = () => {
var modal = document.getElementById("peertube-relink-modal");
if (modal) modal.close();
var block = document.getElementById("format-fichiers-block");
if (block && window.htmx) {
var url = "/admin/fragments/fichiers.php";
if (thesisId) url += `?_thesis_id=${encodeURIComponent(thesisId)}`;
htmx.ajax("GET", url, {
target: "#format-fichiers-block",
swap: "outerHTML",
});
}
};
if (input) {
var pond = FilePond.find(input);
@@ -1035,19 +1031,19 @@
})
.then(() => {
console.log("[pt-relink] addFile resolved");
closeAndRefresh();
closeModal();
})
.catch((err) => {
console.error("[pt-relink] addFile rejected", err);
closeAndRefresh();
closeModal();
});
} else {
console.error("[pt-relink] FilePond.find returned null");
closeAndRefresh();
closeModal();
}
} else {
console.warn("[pt-relink] input not found");
closeAndRefresh();
closeModal();
}
window.__xamxamDirty = true;