release(v2.2.2): feat(folders): show inline folder stats & dates

This commit is contained in:
Ryan
2025-11-29 01:11:26 -05:00
committed by GitHub
parent 997e5067d3
commit d6631adc2d
3 changed files with 231 additions and 124 deletions

View File

@@ -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

View File

@@ -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];

View File

@@ -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, theyre 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 theyre view_own-only here, dont 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, theyre 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 theyre view_own-only here, dont 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