diff --git a/CHANGELOG.md b/CHANGELOG.md index 42a59e6..a792d31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## Changes 11/29/2025 (v2.2.2) + +release(v2.2.2): feat(folders): show inline folder stats & dates + +- Extend FolderModel::countVisible() to track earliest and latest file mtimes +- Format folder created/modified timestamps via DATE_TIME_FORMAT on the backend +- Add a small folder stats cache in fileListView.js to reuse isEmpty.php responses +- Use shared fetchFolderStats() for both folder strip icons and inline folder rows +- Show per-folder item counts, total size, and created/modified dates in inline rows +- Make size parsing more robust by accepting multiple backend size keys (bytes/sizeBytes/size/totalBytes) + +--- + ## Changes 11/28/2025 (v2.2.1) release(v2.2.1): fix(storage-explorer): DOM-safe rendering + docs for disk usage @@ -9,6 +22,8 @@ release(v2.2.1): fix(storage-explorer): DOM-safe rendering + docs for disk usage - Keep deep-delete and pagination behavior unchanged while tightening up XSS/CodeQL concerns. - Update README feature list to mention disk usage summary and Pro storage explorer (ncdu-style) alongside user groups and client portals. +--- + ## Changes 11/28/2025 (v2.2.0) release(v2.2.0): add storage explorer + disk usage scanner diff --git a/public/js/fileListView.js b/public/js/fileListView.js index 8c7867d..6589d3d 100644 --- a/public/js/fileListView.js +++ b/public/js/fileListView.js @@ -295,6 +295,27 @@ try { // Global flag for advanced search mode. window.advancedSearchEnabled = false; +// --- Folder stats cache (for isEmpty.php) --- +const _folderStatsCache = new Map(); + +function fetchFolderStats(folder) { + if (!folder) return Promise.resolve(null); + + if (_folderStatsCache.has(folder)) { + return _folderStatsCache.get(folder); + } + + const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(folder)}&t=${Date.now()}`; + const p = _fetchJSONWithTimeout(url, 2500) + .catch(() => ({ folders: 0, files: 0 })) + .finally(() => { + // keep the resolved value; the Promise itself stays in the map + }); + + _folderStatsCache.set(folder, p); + return p; +} + /* =========================================================== SECURITY: build file URLs only via the API (no /uploads) =========================================================== */ @@ -428,19 +449,19 @@ function attachStripIconAsync(hostEl, fullPath, size = 28) { // make sure this brand-new SVG is sized correctly try { syncFolderIconSizeToRowHeight(); } catch {} - const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(fullPath)}&t=${Date.now()}`; - _fetchJSONWithTimeout(url, 2500) - .then(({ folders = 0, files = 0 }) => { - if ((folders + files) > 0 && iconSpan.dataset.kind !== 'paper') { - // 2) swap to "paper" icon - iconSpan.dataset.kind = 'paper'; - iconSpan.innerHTML = folderSVG('paper'); + fetchFolderStats(fullPath) + .then(stats => { + if (!stats) return; + const folders = Number.isFinite(stats.folders) ? stats.folders : 0; + const files = Number.isFinite(stats.files) ? stats.files : 0; - // re-apply sizing to this new SVG too - try { syncFolderIconSizeToRowHeight(); } catch {} - } - }) - .catch(() => { /* ignore */ }); + if ((folders + files) > 0 && iconSpan.dataset.kind !== 'paper') { + iconSpan.dataset.kind = 'paper'; + iconSpan.innerHTML = folderSVG('paper'); + try { syncFolderIconSizeToRowHeight(); } catch {} + } + }) + .catch(() => {}); } /* ----------------------------- @@ -1156,6 +1177,19 @@ function injectInlineFolderRows(fileListContent, folder, pageSubfolders) { ); if (actionsIdx < 0) actionsIdx = -1; + // NEW: created / modified column indices (uploaded = created in your header) + let createdIdx = headerCells.findIndex(th => + (th.dataset && (th.dataset.column === "uploaded" || th.dataset.column === "created")) || + /\b(uploaded|created)\b/i.test((th.textContent || "").trim()) + ); + if (createdIdx < 0) createdIdx = -1; + + let modifiedIdx = headerCells.findIndex(th => + (th.dataset && th.dataset.column === "modified") || + /\bmodified\b/i.test((th.textContent || "").trim()) + ); + if (modifiedIdx < 0) modifiedIdx = -1; + // Remove any previous folder rows tbody.querySelectorAll("tr.folder-row").forEach(tr => tr.remove()); @@ -1356,19 +1390,32 @@ if (iconSpan) { iconSpan.style.marginTop = "0px"; // small down nudge } - // ----- FOLDER STATS + OWNER + CAPS (keep your existing code below here) ----- - const sizeCellIndex = (sizeIdx >= 0 && sizeIdx < tr.cells.length) ? sizeIdx : -1; - const nameCellIndex = (nameIdx >= 0 && nameIdx < tr.cells.length) ? nameIdx : -1; + // ----- FOLDER STATS + OWNER + CAPS ----- + const sizeCellIndex = (sizeIdx >= 0 && sizeIdx < tr.cells.length) ? sizeIdx : -1; + const nameCellIndex = (nameIdx >= 0 && nameIdx < tr.cells.length) ? nameIdx : -1; + const createdCellIndex = (createdIdx >= 0 && createdIdx < tr.cells.length) ? createdIdx : -1; + const modifiedCellIndex = (modifiedIdx >= 0 && modifiedIdx < tr.cells.length) ? modifiedIdx : -1; - const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(sf.full)}&t=${Date.now()}`; - _fetchJSONWithTimeout(url, 2500).then(stats => { + fetchFolderStats(sf.full).then(stats => { if (!stats) return; const foldersCount = Number.isFinite(stats.folders) ? stats.folders : 0; const filesCount = Number.isFinite(stats.files) ? stats.files : 0; - const bytes = Number.isFinite(stats.bytes) - ? stats.bytes - : (Number.isFinite(stats.sizeBytes) ? stats.sizeBytes : null); + // Try multiple possible size keys so backend + JS can drift a bit + let bytes = null; + const sizeCandidates = [ + stats.bytes, + stats.sizeBytes, + stats.size, + stats.totalBytes + ]; + for (const v of sizeCandidates) { + const n = Number(v); + if (Number.isFinite(n) && n >= 0) { + bytes = n; + break; + } + } let pieces = []; if (foldersCount) pieces.push(`${foldersCount} folder${foldersCount === 1 ? "" : "s"}`); @@ -1395,6 +1442,26 @@ if (iconSpan) { sizeCell.title = `${countLabel}${bytes != null && bytes >= 0 ? " • " + sizeLabel : ""}`; } } + + if (createdCellIndex >= 0) { + const createdCell = tr.cells[createdCellIndex]; + if (createdCell) { + const txt = (stats && typeof stats.earliest_uploaded === 'string') + ? stats.earliest_uploaded + : ''; + createdCell.textContent = txt; + } + } + + if (modifiedCellIndex >= 0) { + const modCell = tr.cells[modifiedCellIndex]; + if (modCell) { + const txt = (stats && typeof stats.latest_mtime === 'string') + ? stats.latest_mtime + : ''; + modCell.textContent = txt; + } + } }).catch(() => { if (sizeCellIndex >= 0) { const sizeCell = tr.cells[sizeCellIndex]; diff --git a/src/models/FolderModel.php b/src/models/FolderModel.php index 535a461..35fed79 100644 --- a/src/models/FolderModel.php +++ b/src/models/FolderModel.php @@ -12,110 +12,135 @@ class FolderModel * ============================================================ */ public static function countVisible(string $folder, string $user, array $perms): array - { - $folder = ACL::normalizeFolder($folder); - - // If the user can't view this folder at all, short-circuit (admin/read/read_own) - $canViewFolder = ACL::isAdmin($perms) - || ACL::canRead($user, $perms, $folder) - || ACL::canReadOwn($user, $perms, $folder); - if (!$canViewFolder) { - return ['folders' => 0, 'files' => 0, 'bytes' => 0]; - } - - // NEW: distinguish full read vs own-only for this folder - $hasFullRead = ACL::isAdmin($perms) || ACL::canRead($user, $perms, $folder); - // if !$hasFullRead but $canViewFolder is true, they’re effectively "view own" only - - $base = realpath((string)UPLOAD_DIR); - if ($base === false) { - return ['folders' => 0, 'files' => 0, 'bytes' => 0]; - } - - // Resolve target dir + ACL-relative prefix - if ($folder === 'root') { - $dir = $base; - $relPrefix = ''; - } else { - $parts = array_filter(explode('/', $folder), fn($p) => $p !== ''); - foreach ($parts as $seg) { - if (!self::isSafeSegment($seg)) { - return ['folders' => 0, 'files' => 0, 'bytes' => 0]; - } - } - $guess = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts); - $dir = self::safeReal($base, $guess); - if ($dir === null || !is_dir($dir)) { - return ['folders' => 0, 'files' => 0, 'bytes' => 0]; - } - $relPrefix = implode('/', $parts); - } - - $IGNORE = ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db']; - $SKIP = ['trash', 'profile_pics']; - - $entries = @scandir($dir); - if ($entries === false) { - return ['folders' => 0, 'files' => 0, 'bytes' => 0]; - } - - $folderCount = 0; - $fileCount = 0; - $totalBytes = 0; - - $MAX_SCAN = 4000; - $scanned = 0; - - foreach ($entries as $name) { - if (++$scanned > $MAX_SCAN) { - break; - } - - if ($name === '.' || $name === '..') continue; - if ($name[0] === '.') continue; - if (in_array($name, $IGNORE, true)) continue; - if (in_array(strtolower($name), $SKIP, true)) continue; - if (!self::isSafeSegment($name)) continue; - - $abs = $dir . DIRECTORY_SEPARATOR . $name; - - if (@is_dir($abs)) { - if (@is_link($abs)) { - $safe = self::safeReal($base, $abs); - if ($safe === null || !is_dir($safe)) { - continue; - } - } - - $childRel = ($relPrefix === '' ? $name : $relPrefix . '/' . $name); - if ( - ACL::isAdmin($perms) - || ACL::canRead($user, $perms, $childRel) - || ACL::canReadOwn($user, $perms, $childRel) - ) { - $folderCount++; - } - } elseif (@is_file($abs)) { - // Only count files if the user has full read on *this* folder. - // If they’re view_own-only here, don’t leak or mis-report counts. - if (!$hasFullRead) { - continue; - } - - $fileCount++; - $sz = @filesize($abs); - if (is_int($sz) && $sz > 0) { - $totalBytes += $sz; - } - } - } - - return [ - 'folders' => $folderCount, - 'files' => $fileCount, - 'bytes' => $totalBytes, - ]; - } +{ + $folder = ACL::normalizeFolder($folder); + + // If the user can't view this folder at all, short-circuit (admin/read/read_own) + $canViewFolder = ACL::isAdmin($perms) + || ACL::canRead($user, $perms, $folder) + || ACL::canReadOwn($user, $perms, $folder); + if (!$canViewFolder) { + return ['folders' => 0, 'files' => 0, 'bytes' => 0]; + } + + // NEW: distinguish full read vs own-only for this folder + $hasFullRead = ACL::isAdmin($perms) || ACL::canRead($user, $perms, $folder); + // if !$hasFullRead but $canViewFolder is true, they’re effectively "view own" only + + $base = realpath((string)UPLOAD_DIR); + if ($base === false) { + return ['folders' => 0, 'files' => 0, 'bytes' => 0]; + } + + // Resolve target dir + ACL-relative prefix + if ($folder === 'root') { + $dir = $base; + $relPrefix = ''; + } else { + $parts = array_filter(explode('/', $folder), fn($p) => $p !== ''); + foreach ($parts as $seg) { + if (!self::isSafeSegment($seg)) { + return ['folders' => 0, 'files' => 0, 'bytes' => 0]; + } + } + $guess = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts); + $dir = self::safeReal($base, $guess); + if ($dir === null || !is_dir($dir)) { + return ['folders' => 0, 'files' => 0, 'bytes' => 0]; + } + $relPrefix = implode('/', $parts); + } + + $IGNORE = ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db']; + $SKIP = ['trash', 'profile_pics']; + + $entries = @scandir($dir); + if ($entries === false) { + return ['folders' => 0, 'files' => 0, 'bytes' => 0]; + } + + $folderCount = 0; + $fileCount = 0; + $totalBytes = 0; + + // NEW: stats for created / modified + $earliestUploaded = null; // min mtime + $latestMtime = null; // max mtime + + $MAX_SCAN = 4000; + $scanned = 0; + + foreach ($entries as $name) { + if (++$scanned > $MAX_SCAN) { + break; + } + + if ($name === '.' || $name === '..') continue; + if ($name[0] === '.') continue; + if (in_array($name, $IGNORE, true)) continue; + if (in_array(strtolower($name), $SKIP, true)) continue; + if (!self::isSafeSegment($name)) continue; + + $abs = $dir . DIRECTORY_SEPARATOR . $name; + + if (@is_dir($abs)) { + if (@is_link($abs)) { + $safe = self::safeReal($base, $abs); + if ($safe === null || !is_dir($safe)) { + continue; + } + } + + $childRel = ($relPrefix === '' ? $name : $relPrefix . '/' . $name); + if ( + ACL::isAdmin($perms) + || ACL::canRead($user, $perms, $childRel) + || ACL::canReadOwn($user, $perms, $childRel) + ) { + $folderCount++; + } + } elseif (@is_file($abs)) { + // Only count files if the user has full read on *this* folder. + // If they’re view_own-only here, don’t leak or mis-report counts. + if (!$hasFullRead) { + continue; + } + + $fileCount++; + $sz = @filesize($abs); + if (is_int($sz) && $sz > 0) { + $totalBytes += $sz; + } + + // NEW: track earliest / latest mtime from visible files + $mt = @filemtime($abs); + if (is_int($mt) && $mt > 0) { + if ($earliestUploaded === null || $mt < $earliestUploaded) { + $earliestUploaded = $mt; + } + if ($latestMtime === null || $mt > $latestMtime) { + $latestMtime = $mt; + } + } + } + } + + $result = [ + 'folders' => $folderCount, + 'files' => $fileCount, + 'bytes' => $totalBytes, + ]; + + // Only include when we actually saw at least one readable file + if ($earliestUploaded !== null) { + $result['earliest_uploaded'] = date(DATE_TIME_FORMAT, $earliestUploaded); + } + if ($latestMtime !== null) { + $result['latest_mtime'] = date(DATE_TIME_FORMAT, $latestMtime); + } + + return $result; +} /* Helpers (private) */ private static function isSafeSegment(string $name): bool