mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
Fix dialog margins, add admin-dialog__body/styles, give trash page horizontal margins
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -16,6 +16,11 @@ app/storage/logs/admin.log
|
|||||||
app/storage/cache/rate_limit/*
|
app/storage/cache/rate_limit/*
|
||||||
!app/storage/cache/rate_limit/.gitkeep
|
!app/storage/cache/rate_limit/.gitkeep
|
||||||
|
|
||||||
|
# FilePond tmp uploads + trash (keep .gitkeep)
|
||||||
|
app/storage/tmp/filepond/*
|
||||||
|
!app/storage/tmp/filepond/.gitkeep
|
||||||
|
app/storage/tmp/_trash/*
|
||||||
|
|
||||||
# Thesis storage (keep .gitkeep)
|
# Thesis storage (keep .gitkeep)
|
||||||
app/storage/theses/*
|
app/storage/theses/*
|
||||||
!app/storage/theses/.gitkeep
|
!app/storage/theses/.gitkeep
|
||||||
|
|||||||
17
TODO.md
17
TODO.md
@@ -1,5 +1,22 @@
|
|||||||
# Current tasks
|
# Current tasks
|
||||||
|
|
||||||
|
## Tmp file cleanup (stale filepond + _trash)
|
||||||
|
- [x] Session-based detection: check manifest session_id against PHP session files
|
||||||
|
- [x] DB-based detection for _trash: check thesis_files row still exists
|
||||||
|
- [x] Time-based fallback: >2h filepond, >30d trash
|
||||||
|
- [x] admin cleanup-stats.php: stale vs active breakdown with sizes
|
||||||
|
- [x] admin cleanup-tmp.php: smart cleanup with detailed JSON response
|
||||||
|
- [x] admin index: Nettoyer button + dialog with stats and cleanup trigger
|
||||||
|
- [x] .gitignore: exclude tmp/filepond/* and tmp/_trash/*
|
||||||
|
- [ ] Deploy: just deploy
|
||||||
|
|
||||||
|
## Dialog & trash page margins
|
||||||
|
- [x] Add admin-dialog__body CSS rule with padding + margin resets
|
||||||
|
- [x] Add admin-dialog__stats + admin-dialog__hint classes
|
||||||
|
- [x] Fix admin-dialog__alert p margins (not-last-child gets bottom margin)
|
||||||
|
- [x] Add horizontal margins to .admin-main--list > direct children (trash page forms, tables, flash msgs)
|
||||||
|
- [x] Clean up tmp-cleanup dialog inline styles → CSS classes
|
||||||
|
|
||||||
## Deploy exclusions
|
## Deploy exclusions
|
||||||
- [x] Exclude storage/tmp/ (not just filepond/*) to skip _trash dirs with bad perms
|
- [x] Exclude storage/tmp/ (not just filepond/*) to skip _trash dirs with bad perms
|
||||||
- [x] Exclude storage/documents/ and storage/theses/ from rsync deploy
|
- [x] Exclude storage/documents/ and storage/theses/ from rsync deploy
|
||||||
|
|||||||
192
app/public/admin/actions/cleanup-stats.php
Normal file
192
app/public/admin/actions/cleanup-stats.php
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Temp file statistics endpoint (admin).
|
||||||
|
*
|
||||||
|
* GET /admin/actions/cleanup-stats.php
|
||||||
|
*
|
||||||
|
* Returns JSON with counts and sizes of tmp/filepond/ and tmp/_trash/,
|
||||||
|
* broken down by stale (will be cleaned) vs active (still session-bound).
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||||
|
|
||||||
|
AdminAuth::requireLogin();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
http_response_code(405);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$storageRoot = STORAGE_ROOT;
|
||||||
|
$filepondDir = $storageRoot . '/tmp/filepond';
|
||||||
|
$trashDir = $storageRoot . '/tmp/_trash';
|
||||||
|
$maxAgeFilepond = 7200; // 2h
|
||||||
|
$maxAgeTrash = 2592000; // 30d
|
||||||
|
|
||||||
|
$sessionSavePath = session_save_path();
|
||||||
|
if (!$sessionSavePath || $sessionSavePath === '') {
|
||||||
|
$sessionSavePath = sys_get_temp_dir();
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
// ── FilePond stats ───────────────────────────────────────────────────────
|
||||||
|
$fpStaleCount = 0;
|
||||||
|
$fpStaleSize = 0;
|
||||||
|
$fpActiveCount = 0;
|
||||||
|
$fpActiveSize = 0;
|
||||||
|
|
||||||
|
if (is_dir($filepondDir)) {
|
||||||
|
$items = @scandir($filepondDir);
|
||||||
|
if ($items !== false) {
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if ($item === '.' || $item === '..' || $item === '.gitkeep') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$dirPath = $filepondDir . '/' . $item;
|
||||||
|
if (!is_dir($dirPath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$size = dirSizeRecursive($dirPath);
|
||||||
|
$mtime = filemtime($dirPath);
|
||||||
|
$ageMinutes = (int)(($now - $mtime) / 60);
|
||||||
|
|
||||||
|
$stale = false;
|
||||||
|
|
||||||
|
// Session-based detection
|
||||||
|
$manifestPath = $dirPath . '/manifest.json';
|
||||||
|
if (file_exists($manifestPath)) {
|
||||||
|
$manifest = json_decode(file_get_contents($manifestPath), true);
|
||||||
|
if (is_array($manifest) && !empty($manifest['session_id'])) {
|
||||||
|
$sessionFile = $sessionSavePath . '/sess_' . $manifest['session_id'];
|
||||||
|
if (!file_exists($sessionFile)) {
|
||||||
|
$stale = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time-based fallback
|
||||||
|
if (!$stale && $ageMinutes > ($maxAgeFilepond / 60)) {
|
||||||
|
$stale = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stale) {
|
||||||
|
$fpStaleCount++;
|
||||||
|
$fpStaleSize += $size;
|
||||||
|
} else {
|
||||||
|
$fpActiveCount++;
|
||||||
|
$fpActiveSize += $size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Trash stats ──────────────────────────────────────────────────────────
|
||||||
|
require_once __DIR__ . '/../../../src/Database.php';
|
||||||
|
$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;
|
||||||
|
$trActiveCount = 0;
|
||||||
|
$trActiveSize = 0;
|
||||||
|
|
||||||
|
if (is_dir($trashDir)) {
|
||||||
|
$items = @scandir($trashDir);
|
||||||
|
if ($items !== false) {
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if ($item === '.' || $item === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$filePath = $trashDir . '/' . $item;
|
||||||
|
if (!is_file($filePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$size = filesize($filePath);
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$stale && $ageDays > ($maxAgeTrash / 86400)) {
|
||||||
|
$stale = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stale) {
|
||||||
|
$trStaleCount++;
|
||||||
|
$trStaleSize += $size;
|
||||||
|
} else {
|
||||||
|
$trActiveCount++;
|
||||||
|
$trActiveSize += $size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode([
|
||||||
|
'filepond_stale_count' => $fpStaleCount,
|
||||||
|
'filepond_stale_size' => $fpStaleSize,
|
||||||
|
'filepond_stale_human' => humanBytes($fpStaleSize),
|
||||||
|
'filepond_active_count' => $fpActiveCount,
|
||||||
|
'filepond_active_size' => $fpActiveSize,
|
||||||
|
'filepond_active_human' => humanBytes($fpActiveSize),
|
||||||
|
'trash_stale_count' => $trStaleCount,
|
||||||
|
'trash_stale_size' => $trStaleSize,
|
||||||
|
'trash_stale_human' => humanBytes($trStaleSize),
|
||||||
|
'trash_active_count' => $trActiveCount,
|
||||||
|
'trash_active_size' => $trActiveSize,
|
||||||
|
'trash_active_human' => humanBytes($trActiveSize),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
function dirSizeRecursive(string $dir): int
|
||||||
|
{
|
||||||
|
$size = 0;
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$items = @scandir($dir);
|
||||||
|
if ($items === false) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if ($item === '.' || $item === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$path = $dir . '/' . $item;
|
||||||
|
if (is_dir($path)) {
|
||||||
|
$size += dirSizeRecursive($path);
|
||||||
|
} else {
|
||||||
|
$size += filesize($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $size;
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanBytes(int $bytes): string
|
||||||
|
{
|
||||||
|
if ($bytes > 1073741824) {
|
||||||
|
return number_format($bytes / 1073741824, 1) . ' GB';
|
||||||
|
}
|
||||||
|
if ($bytes > 1048576) {
|
||||||
|
return number_format($bytes / 1048576, 1) . ' MB';
|
||||||
|
}
|
||||||
|
return number_format($bytes / 1024, 1) . ' KB';
|
||||||
|
}
|
||||||
194
app/public/admin/actions/cleanup-tmp.php
Normal file
194
app/public/admin/actions/cleanup-tmp.php
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* On-demand temp file cleanup (admin).
|
||||||
|
*
|
||||||
|
* POST /admin/actions/cleanup-tmp.php
|
||||||
|
*
|
||||||
|
* Smart cleanup: detects truly orphaned files by checking:
|
||||||
|
* - filepond dirs: whose PHP session no longer exists (abandoned upload)
|
||||||
|
* - _trash files: whose DB thesis_files row is gone (thesis already hard-deleted)
|
||||||
|
*
|
||||||
|
* Falls back to time-based (>2h filepond, >30d trash) as safety net.
|
||||||
|
* Returns JSON with counts of removed items.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||||
|
|
||||||
|
AdminAuth::requireLogin();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|
||||||
|
|| !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(['error' => 'CSRF invalide.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../src/Database.php';
|
||||||
|
|
||||||
|
$storageRoot = STORAGE_ROOT;
|
||||||
|
$filepondDir = $storageRoot . '/tmp/filepond';
|
||||||
|
$trashDir = $storageRoot . '/tmp/_trash';
|
||||||
|
$maxAgeFilepond = 7200; // 2 hours (fallback)
|
||||||
|
$maxAgeTrash = 2592000; // 30 days (fallback)
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$filepondRemoved = 0;
|
||||||
|
$trashRemoved = 0;
|
||||||
|
$errors = [];
|
||||||
|
$details = [];
|
||||||
|
|
||||||
|
$db = new Database();
|
||||||
|
$pdo = $db->getPDO();
|
||||||
|
|
||||||
|
// ── Determine PHP session save path ──────────────────────────────────────
|
||||||
|
$sessionSavePath = session_save_path();
|
||||||
|
if (!$sessionSavePath || $sessionSavePath === '') {
|
||||||
|
$sessionSavePath = sys_get_temp_dir();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cleanup stale filepond dirs ──────────────────────────────────────────
|
||||||
|
if (is_dir($filepondDir)) {
|
||||||
|
$items = @scandir($filepondDir);
|
||||||
|
if ($items === false) {
|
||||||
|
$errors[] = 'Impossible de lire tmp/filepond/.';
|
||||||
|
} else {
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if ($item === '.' || $item === '..' || $item === '.gitkeep') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$dirPath = $filepondDir . '/' . $item;
|
||||||
|
if (!is_dir($dirPath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$shouldDelete = false;
|
||||||
|
$reason = '';
|
||||||
|
|
||||||
|
$manifestPath = $dirPath . '/manifest.json';
|
||||||
|
$mtime = filemtime($dirPath);
|
||||||
|
$ageMinutes = (int)(($now - $mtime) / 60);
|
||||||
|
|
||||||
|
// Strategy 1: session-based (preferred)
|
||||||
|
if (file_exists($manifestPath)) {
|
||||||
|
$manifest = json_decode(file_get_contents($manifestPath), true);
|
||||||
|
if (is_array($manifest) && !empty($manifest['session_id'])) {
|
||||||
|
$sessionFile = $sessionSavePath . '/sess_' . $manifest['session_id'];
|
||||||
|
if (!file_exists($sessionFile)) {
|
||||||
|
$shouldDelete = true;
|
||||||
|
$reason = "session expirée (" . $manifest['session_id'] . ")";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: time-based fallback (no manifest or session still exists but very old)
|
||||||
|
if (!$shouldDelete && $ageMinutes > ($maxAgeFilepond / 60)) {
|
||||||
|
$shouldDelete = true;
|
||||||
|
$reason = "plus de " . ($maxAgeFilepond / 3600) . "h";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($shouldDelete) {
|
||||||
|
rmdirRecursive($dirPath);
|
||||||
|
$filepondRemoved++;
|
||||||
|
$details[] = "filepond/$item: $reason";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cleanup orphaned _trash files ────────────────────────────────────────
|
||||||
|
if (is_dir($trashDir)) {
|
||||||
|
// Build a set of existing thesis_files IDs for fast lookup
|
||||||
|
$existingFileIds = [];
|
||||||
|
$stmt = $pdo->query('SELECT id FROM thesis_files');
|
||||||
|
while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
|
||||||
|
$existingFileIds[(int)$row['id']] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also collect thesis_ids from theses table (a file might refer to a hard-deleted thesis)
|
||||||
|
// — but thesis_files rows are CASCADE-deleted when a thesis is hard-deleted, so if the
|
||||||
|
// thesis_files row is gone, the trash file is truly orphaned.
|
||||||
|
|
||||||
|
$items = @scandir($trashDir);
|
||||||
|
if ($items === false) {
|
||||||
|
$errors[] = 'Impossible de lire tmp/_trash/.';
|
||||||
|
} else {
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if ($item === '.' || $item === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$filePath = $trashDir . '/' . $item;
|
||||||
|
if (!is_file($filePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$shouldDelete = false;
|
||||||
|
$reason = '';
|
||||||
|
|
||||||
|
$mtime = filemtime($filePath);
|
||||||
|
$ageDays = (int)(($now - $mtime) / 86400);
|
||||||
|
|
||||||
|
// Strategy 1: DB-based — file was trashed by deleteThesisFile(id=N),
|
||||||
|
// file is named "N_basename". If thesis_files row N no longer exists, it's orphaned.
|
||||||
|
if (preg_match('/^(\d+)_/', $item, $m)) {
|
||||||
|
$dbId = (int)$m[1];
|
||||||
|
if (!isset($existingFileIds[$dbId])) {
|
||||||
|
$shouldDelete = true;
|
||||||
|
$reason = "ligne DB thesis_files#$dbId supprimée";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: time-based fallback
|
||||||
|
if (!$shouldDelete && $ageDays > ($maxAgeTrash / 86400)) {
|
||||||
|
$shouldDelete = true;
|
||||||
|
$reason = "plus de " . ($maxAgeTrash / 86400) . "j";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($shouldDelete) {
|
||||||
|
if (!@unlink($filePath)) {
|
||||||
|
$errors[] = "Impossible de supprimer: $item";
|
||||||
|
} else {
|
||||||
|
$trashRemoved++;
|
||||||
|
$details[] = "_trash/$item: $reason";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'filepond_removed' => $filepondRemoved,
|
||||||
|
'trash_removed' => $trashRemoved,
|
||||||
|
'errors' => $errors,
|
||||||
|
'details' => $details,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Helper ────────────────────────────────────────────────────────────────
|
||||||
|
function rmdirRecursive(string $dir): void
|
||||||
|
{
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$items = @scandir($dir);
|
||||||
|
if ($items === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if ($item === '.' || $item === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$path = $dir . '/' . $item;
|
||||||
|
if (is_dir($path)) {
|
||||||
|
rmdirRecursive($path);
|
||||||
|
} else {
|
||||||
|
@unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@rmdir($dir);
|
||||||
|
}
|
||||||
@@ -473,6 +473,34 @@ try {
|
|||||||
$trashCount = $db->countTrashedTheses();
|
$trashCount = $db->countTrashedTheses();
|
||||||
$tab = $_GET['tab'] ?? 'list';
|
$tab = $_GET['tab'] ?? 'list';
|
||||||
$trashedTheses = ($tab === 'trash') ? $db->getTrashedTheses() : [];
|
$trashedTheses = ($tab === 'trash') ? $db->getTrashedTheses() : [];
|
||||||
|
|
||||||
|
// ── Tmp file stats ───────────────────────────────────────────────────
|
||||||
|
$filepondDir = STORAGE_ROOT . '/tmp/filepond';
|
||||||
|
$trashDir = STORAGE_ROOT . '/tmp/_trash';
|
||||||
|
$tmpFilepondCount = 0;
|
||||||
|
$tmpTrashCount = 0;
|
||||||
|
$tmpTotalCount = 0;
|
||||||
|
if (is_dir($filepondDir)) {
|
||||||
|
$items = @scandir($filepondDir);
|
||||||
|
if ($items !== false) {
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if ($item !== '.' && $item !== '..' && $item !== '.gitkeep') {
|
||||||
|
$tmpFilepondCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (is_dir($trashDir)) {
|
||||||
|
$items = @scandir($trashDir);
|
||||||
|
if ($items !== false) {
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if ($item !== '.' && $item !== '..') {
|
||||||
|
$tmpTrashCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$tmpTotalCount = $tmpFilepondCount + $tmpTrashCount;
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log("Error loading theses list: " . $e->getMessage());
|
error_log("Error loading theses list: " . $e->getMessage());
|
||||||
die("Erreur lors du chargement de la liste.");
|
die("Erreur lors du chargement de la liste.");
|
||||||
|
|||||||
@@ -61,6 +61,15 @@
|
|||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-main--list > form,
|
||||||
|
.admin-main--list > table,
|
||||||
|
.admin-main--list > .flash-success,
|
||||||
|
.admin-main--list > .flash-error,
|
||||||
|
.admin-main--list > .admin-empty {
|
||||||
|
margin-left: var(--space-m);
|
||||||
|
margin-right: var(--space-m);
|
||||||
|
}
|
||||||
|
|
||||||
#admin-table-wrap {
|
#admin-table-wrap {
|
||||||
padding: 0 0 var(--space-2xl);
|
padding: 0 0 var(--space-2xl);
|
||||||
}
|
}
|
||||||
@@ -996,10 +1005,43 @@ th.admin-ap-col {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-dialog__alert p {
|
.admin-dialog__alert p:not(:last-child) {
|
||||||
|
margin: 0 0 var(--space-xs) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dialog__alert p:last-child {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-dialog__body {
|
||||||
|
padding: var(--space-m) var(--space-l);
|
||||||
|
font-size: var(--step--1);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dialog__body > *:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dialog__body > *:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dialog__stats {
|
||||||
|
margin: var(--space-sm) 0;
|
||||||
|
padding: var(--space-sm);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: var(--step--1);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dialog__hint {
|
||||||
|
font-size: var(--step--2);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: var(--space-xs) 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-dialog__footer {
|
.admin-dialog__footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 772 KiB |
@@ -1 +0,0 @@
|
|||||||
{"queue_type":"cover","original_name":"2026-03-30-112553_hyprshot.png","mime":"image/png","ext":"png","size":790551,"session_id":"ca4d45dbfb0b3fc4f87f725826df9d35","uploaded_at":"2026-05-12T11:03:34+00:00"}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 772 KiB |
@@ -1 +0,0 @@
|
|||||||
{"queue_type":"cover","original_name":"2026-03-30-112553_hyprshot.png","mime":"image/png","ext":"png","size":790551,"session_id":"ca4d45dbfb0b3fc4f87f725826df9d35","uploaded_at":"2026-05-12T10:50:23+00:00"}
|
|
||||||
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
{"queue_type":"note_intention","original_name":"nixing_the_fix_report_final_5521_630pm-508_002.pdf","mime":"application/pdf","ext":"pdf","size":1296086,"session_id":"ca4d45dbfb0b3fc4f87f725826df9d35","uploaded_at":"2026-05-12T11:03:37+00:00"}
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
{"queue_type":"annexe","original_name":"Nixing the Fix_ An FTC Report to Congress on Repair Restrictions - nixing_the_fix_report_final_5521_630pm-508_002.pdf","mime":"application/pdf","ext":"pdf","size":5861686,"session_id":"ca4d45dbfb0b3fc4f87f725826df9d35","uploaded_at":"2026-05-12T10:50:16+00:00"}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 166 KiB |
@@ -1 +0,0 @@
|
|||||||
{"queue_type":"cover","original_name":"2026-05-06-105104_hyprshot.png","mime":"image/png","ext":"png","size":170350,"session_id":"60cb0e3107c8795cc3c0d2b8c222953e","uploaded_at":"2026-05-13T12:18:50+00:00"}
|
|
||||||
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
{"queue_type":"note_intention","original_name":"invoice_25-12-01_251200002_les-iles-mardi.pdf","mime":"application/pdf","ext":"pdf","size":349083,"session_id":"ca4d45dbfb0b3fc4f87f725826df9d35","uploaded_at":"2026-05-12T10:30:01+00:00"}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 772 KiB |
@@ -1 +0,0 @@
|
|||||||
{"queue_type":"cover","original_name":"2026-03-30-112553_hyprshot.png","mime":"image/png","ext":"png","size":790551,"session_id":"ca4d45dbfb0b3fc4f87f725826df9d35","uploaded_at":"2026-05-12T10:46:44+00:00"}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 132 KiB |
@@ -1 +0,0 @@
|
|||||||
{"queue_type":"cover","original_name":"Screenshot_2026-05-08_at_11-25-04_DepNum.png","mime":"image/png","ext":"png","size":134916,"session_id":"ca4d45dbfb0b3fc4f87f725826df9d35","uploaded_at":"2026-05-12T10:30:08+00:00"}
|
|
||||||
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
{"queue_type":"note_intention","original_name":"Nixing the Fix_ An FTC Report to Congress on Repair Restrictions - nixing_the_fix_report_final_5521_630pm-508_002.pdf","mime":"application/pdf","ext":"pdf","size":5861686,"session_id":"ca4d45dbfb0b3fc4f87f725826df9d35","uploaded_at":"2026-05-12T10:46:38+00:00"}
|
|
||||||
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
{"queue_type":"note_intention","original_name":"Proposition_proce\u0301dure_licences_V2.pdf","mime":"application/pdf","ext":"pdf","size":32119,"session_id":"ca4d45dbfb0b3fc4f87f725826df9d35","uploaded_at":"2026-05-12T10:50:27+00:00"}
|
|
||||||
@@ -37,6 +37,12 @@ document.addEventListener('htmx:afterSwap',()=>{document.querySelectorAll('input
|
|||||||
Corbeille (<?= $trashCount ?>)
|
Corbeille (<?= $trashCount ?>)
|
||||||
</a>
|
</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?php if ($tmpTotalCount > 0): ?>
|
||||||
|
<button type="button" class="btn btn--sm btn--secondary" id="tmp-cleanup-btn"
|
||||||
|
onclick="document.getElementById('tmp-cleanup-dialog').showModal(); fetchTmpStats()">
|
||||||
|
Nettoyer (<?= $tmpTotalCount ?>)
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
<button type="button" class="btn btn--primary btn--sm" id="import-dialog-btn"
|
<button type="button" class="btn btn--primary btn--sm" id="import-dialog-btn"
|
||||||
onclick="document.getElementById('import-dialog').showModal()">
|
onclick="document.getElementById('import-dialog').showModal()">
|
||||||
Importer
|
Importer
|
||||||
@@ -275,6 +281,125 @@ document.addEventListener('htmx:afterSwap',()=>{document.querySelectorAll('input
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════════
|
||||||
|
TMP CLEANUP DIALOG
|
||||||
|
══════════════════════════════════════════════════════════════ -->
|
||||||
|
<dialog id="tmp-cleanup-dialog" class="admin-dialog admin-dialog--sm" aria-labelledby="tmp-cleanup-title">
|
||||||
|
<div class="admin-dialog__header">
|
||||||
|
<h2 id="tmp-cleanup-title">Nettoyer les fichiers temporaires</h2>
|
||||||
|
<button type="button" class="admin-dialog__close" aria-label="Fermer"
|
||||||
|
onclick="document.getElementById('tmp-cleanup-dialog').close()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="admin-dialog__body">
|
||||||
|
<p>Les fichiers temporaires s'accumulent lorsque des téléversements sont abandonnés (formulaire fermé avant envoi).</p>
|
||||||
|
<div id="tmp-cleanup-stats" class="admin-dialog__stats">
|
||||||
|
Chargement…
|
||||||
|
</div>
|
||||||
|
<p class="admin-dialog__hint">
|
||||||
|
Seuls les fichiers de plus de 2 heures (FilePond) et 30 jours (corbeille) seront supprimés.
|
||||||
|
Les téléversements récents sont conservés.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="tmp-cleanup-result" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-dialog__footer">
|
||||||
|
<button type="button" class="btn btn--danger" id="tmp-cleanup-confirm"
|
||||||
|
onclick="executeTmpCleanup()">
|
||||||
|
Nettoyer
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn--secondary"
|
||||||
|
onclick="document.getElementById('tmp-cleanup-dialog').close()">Annuler</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function fetchTmpStats() {
|
||||||
|
const el = document.getElementById('tmp-cleanup-stats');
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/admin/actions/cleanup-stats.php');
|
||||||
|
const data = await resp.json();
|
||||||
|
let html = '';
|
||||||
|
const totalStale = data.filepond_stale_count + data.trash_stale_count;
|
||||||
|
|
||||||
|
if (totalStale === 0) {
|
||||||
|
html = '<p style="margin:0;color:var(--accent-green)">✓ Aucun fichier obsolète à nettoyer.</p>';
|
||||||
|
if ((data.filepond_active_count || 0) + (data.trash_active_count || 0) > 0) {
|
||||||
|
html += '<p style="margin:var(--space-xs) 0 0 0;font-size:0.85em;color:var(--text-secondary)">';
|
||||||
|
if (data.filepond_active_count) html += `📁 ${data.filepond_active_count} téléversement(s) actif(s) (session existante) — ${data.filepond_active_human}<br>`;
|
||||||
|
if (data.trash_active_count) html += `🗑️ ${data.trash_active_count} fichier(s) récent(s) en corbeille — ${data.trash_active_human}`;
|
||||||
|
html += '</p>';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html = `<p style="margin:0 0 var(--space-xs) 0;font-weight:600">⚠️ ${totalStale} élément(s) obsolète(s) à nettoyer :</p>`;
|
||||||
|
if (data.filepond_stale_count) html += `<p style="margin:0 0 var(--space-xs) 0">📁 <strong>Téléversements abandonnés</strong> : ${data.filepond_stale_count} dossier(s) — ${data.filepond_stale_human} <span style="font-size:0.85em;color:var(--text-secondary)">(session expirée ou >2h)</span></p>`;
|
||||||
|
if (data.trash_stale_count) html += `<p style="margin:0 0 var(--space-xs) 0">🗑️ <strong>Fichiers supprimés orphelins</strong> : ${data.trash_stale_count} fichier(s) — ${data.trash_stale_human} <span style="font-size:0.85em;color:var(--text-secondary)">(référence DB disparue ou >30j)</span></p>`;
|
||||||
|
if (data.filepond_active_count || data.trash_active_count) {
|
||||||
|
html += '<p style="margin:var(--space-xs) 0 0 0;font-size:0.85em;color:var(--text-secondary)">Conservés : ';
|
||||||
|
if (data.filepond_active_count) html += `${data.filepond_active_count} téléversement(s) actif(s), `;
|
||||||
|
if (data.trash_active_count) html += `${data.trash_active_count} fichier(s) récent(s)`;
|
||||||
|
html += '</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el.innerHTML = html;
|
||||||
|
|
||||||
|
if (totalStale === 0) {
|
||||||
|
document.getElementById('tmp-cleanup-confirm').disabled = true;
|
||||||
|
document.getElementById('tmp-cleanup-confirm').textContent = 'Rien à nettoyer';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
el.textContent = 'Erreur lors du chargement des statistiques.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeTmpCleanup() {
|
||||||
|
const btn = document.getElementById('tmp-cleanup-confirm');
|
||||||
|
const result = document.getElementById('tmp-cleanup-result');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Nettoyage…';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/admin/actions/cleanup-tmp.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: 'csrf_token=' + encodeURIComponent('<?= htmlspecialchars($_SESSION['csrf_token']) ?>')
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
result.style.display = 'block';
|
||||||
|
if (data.success) {
|
||||||
|
const total = data.filepond_removed + data.trash_removed;
|
||||||
|
if (total === 0) {
|
||||||
|
result.className = 'flash-success';
|
||||||
|
result.innerHTML = '✓ Aucun fichier obsolète trouvé.';
|
||||||
|
} else {
|
||||||
|
result.className = 'flash-success';
|
||||||
|
let msg = `✓ ${total} élément(s) supprimé(s) : `;
|
||||||
|
if (data.filepond_removed) msg += `${data.filepond_removed} téléversement(s) abandonné(s), `;
|
||||||
|
if (data.trash_removed) msg += `${data.trash_removed} fichier(s) orphelin(s)`;
|
||||||
|
result.innerHTML = msg;
|
||||||
|
if (data.details && data.details.length > 0) {
|
||||||
|
result.innerHTML += '<details style="margin-top:var(--space-xs);font-size:0.85em"><summary>Détails (' + data.details.length + ')</summary><ul style="margin:var(--space-xs) 0 0 var(--space-md)">' +
|
||||||
|
data.details.map(d => '<li>' + d + '</li>').join('') + '</ul></details>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById('tmp-cleanup-dialog').close();
|
||||||
|
window.location.reload();
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
result.className = 'flash-error';
|
||||||
|
result.textContent = 'Erreur : ' + (data.error || 'inconnue');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Réessayer';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
result.style.display = 'block';
|
||||||
|
result.className = 'flash-error';
|
||||||
|
result.textContent = 'Erreur réseau.';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Réessayer';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<?php if ($importMessage || !empty($importErrors)): ?>
|
<?php if ($importMessage || !empty($importErrors)): ?>
|
||||||
<script>document.getElementById('import-dialog').showModal();</script>
|
<script>document.getElementById('import-dialog').showModal();</script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
4
justfile
4
justfile
@@ -318,6 +318,10 @@ trigger-backup:
|
|||||||
# Manually trigger the backup script on the server now (doesn't wait for cron).
|
# Manually trigger the backup script on the server now (doesn't wait for cron).
|
||||||
ssh -t xamxam "sudo -u www-data /usr/local/bin/backup-sqlite.sh"
|
ssh -t xamxam "sudo -u www-data /usr/local/bin/backup-sqlite.sh"
|
||||||
|
|
||||||
|
[group('deploy')]
|
||||||
|
deploy-all-first: deploy deploy-backup
|
||||||
|
# One-shot: full initial deploy including backup cron.
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Testing
|
# Testing
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user