Compare commits

..

5 Commits

Author SHA1 Message Date
github-actions[bot]
4b83facc97 chore(release): set APP_VERSION to v2.2.3 [skip ci] 2025-11-30 04:26:08 +00:00
Ryan
3e473d57b4 release(v2.2.3): round gallery card corners in file grid 2025-11-29 23:25:57 -05:00
Ryan
f2ce43f18f fix(preview): harden SVG handling and normalize mime type 2025-11-29 23:11:50 -05:00
github-actions[bot]
a50fa30db2 chore(release): set APP_VERSION to v2.2.2 [skip ci] 2025-11-29 06:11:37 +00:00
Ryan
d6631adc2d release(v2.2.2): feat(folders): show inline folder stats & dates 2025-11-29 01:11:26 -05:00
8 changed files with 307 additions and 153 deletions

View File

@@ -1,5 +1,30 @@
# Changelog # Changelog
## Changes 11/29/2025 (v2.2.3)
fix(preview): harden SVG handling and normalize mime type
release(v2.2.3): round gallery card corners in file grid
- Stop treating SVGs as inline-previewable images in file list and preview modal
- Show a clear “SVG preview disabled for security reasons” message instead
- Keep SVGs downloadable via /api/file/download.php with proper image/svg+xml MIME
- Add i18n key for svg_preview_disabled
---
## 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) ## Changes 11/28/2025 (v2.2.1)
release(v2.2.1): fix(storage-explorer): DOM-safe rendering + docs for disk usage release(v2.2.1): fix(storage-explorer): DOM-safe rendering + docs for disk usage
@@ -9,6 +34,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. - 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. - 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) ## Changes 11/28/2025 (v2.2.0)
release(v2.2.0): add storage explorer + disk usage scanner release(v2.2.0): add storage explorer + disk usage scanner

View File

@@ -179,9 +179,22 @@ export function buildFileTableRow(file, folderPath) {
const safeUploader = escapeHTML(file.uploader || "Unknown"); const safeUploader = escapeHTML(file.uploader || "Unknown");
let previewButton = ""; let previewButton = "";
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tif|tiff|eps|heic|pdf|mp4|webm|mov|mp3|wav|m4a|ogg|flac|aac|wma|opus|mkv|ogv)$/i.test(file.name)) {
const isSvg = /\.svg$/i.test(file.name);
// IMPORTANT: do NOT treat SVG as previewable
if (
!isSvg &&
/\.(jpg|jpeg|png|gif|bmp|webp|ico|tif|tiff|eps|heic|pdf|mp4|webm|mov|mp3|wav|m4a|ogg|flac|aac|wma|opus|mkv|ogv)$/i
.test(file.name)
) {
let previewIcon = ""; let previewIcon = "";
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tif|tiff|eps|heic)$/i.test(file.name)) {
// images (SVG explicitly excluded)
if (
/\.(jpg|jpeg|png|gif|bmp|webp|ico|tif|tiff|eps|heic)$/i
.test(file.name)
) {
previewIcon = `<i class="material-icons">image</i>`; previewIcon = `<i class="material-icons">image</i>`;
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(file.name)) { } else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(file.name)) {
previewIcon = `<i class="material-icons">videocam</i>`; previewIcon = `<i class="material-icons">videocam</i>`;
@@ -190,14 +203,16 @@ export function buildFileTableRow(file, folderPath) {
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) { } else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
previewIcon = `<i class="material-icons">audiotrack</i>`; previewIcon = `<i class="material-icons">audiotrack</i>`;
} }
previewButton = `<button
type="button" previewButton = `
class="btn btn-sm btn-info preview-btn" <button
data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}" type="button"
data-preview-name="${safeFileName}" class="btn btn-sm btn-info preview-btn"
title="${t('preview')}"> data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
${previewIcon} data-preview-name="${safeFileName}"
</button>`; title="${t('preview')}">
${previewIcon}
</button>`;
} }
return ` return `
@@ -242,13 +257,13 @@ export function buildFileTableRow(file, folderPath) {
<i class="material-icons">drive_file_rename_outline</i> <i class="material-icons">drive_file_rename_outline</i>
</button> </button>
<!-- share --> <!-- share -->
<button <button
type="button" type="button"
class="btn btn-secondary btn-sm share-btn ms-1" class="btn btn-secondary btn-sm share-btn ms-1"
data-file="${safeFileName}" data-file="${safeFileName}"
title="${t('share')}"> title="${t('share')}">
<i class="material-icons">share</i> <i class="material-icons">share</i>
</button> </button>
</div> </div>
</td> </td>
</tr> </tr>

View File

@@ -295,6 +295,27 @@ try {
// Global flag for advanced search mode. // Global flag for advanced search mode.
window.advancedSearchEnabled = false; 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) 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 // make sure this brand-new SVG is sized correctly
try { syncFolderIconSizeToRowHeight(); } catch {} try { syncFolderIconSizeToRowHeight(); } catch {}
const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(fullPath)}&t=${Date.now()}`; fetchFolderStats(fullPath)
_fetchJSONWithTimeout(url, 2500) .then(stats => {
.then(({ folders = 0, files = 0 }) => { if (!stats) return;
if ((folders + files) > 0 && iconSpan.dataset.kind !== 'paper') { const folders = Number.isFinite(stats.folders) ? stats.folders : 0;
// 2) swap to "paper" icon const files = Number.isFinite(stats.files) ? stats.files : 0;
iconSpan.dataset.kind = 'paper';
iconSpan.innerHTML = folderSVG('paper');
// re-apply sizing to this new SVG too if ((folders + files) > 0 && iconSpan.dataset.kind !== 'paper') {
try { syncFolderIconSizeToRowHeight(); } catch {} iconSpan.dataset.kind = 'paper';
} iconSpan.innerHTML = folderSVG('paper');
}) try { syncFolderIconSizeToRowHeight(); } catch {}
.catch(() => { /* ignore */ }); }
})
.catch(() => {});
} }
/* ----------------------------- /* -----------------------------
@@ -1156,6 +1177,19 @@ function injectInlineFolderRows(fileListContent, folder, pageSubfolders) {
); );
if (actionsIdx < 0) actionsIdx = -1; 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 // Remove any previous folder rows
tbody.querySelectorAll("tr.folder-row").forEach(tr => tr.remove()); tbody.querySelectorAll("tr.folder-row").forEach(tr => tr.remove());
@@ -1356,19 +1390,32 @@ if (iconSpan) {
iconSpan.style.marginTop = "0px"; // small down nudge iconSpan.style.marginTop = "0px"; // small down nudge
} }
// ----- FOLDER STATS + OWNER + CAPS (keep your existing code below here) ----- // ----- FOLDER STATS + OWNER + CAPS -----
const sizeCellIndex = (sizeIdx >= 0 && sizeIdx < tr.cells.length) ? sizeIdx : -1; const sizeCellIndex = (sizeIdx >= 0 && sizeIdx < tr.cells.length) ? sizeIdx : -1;
const nameCellIndex = (nameIdx >= 0 && nameIdx < tr.cells.length) ? nameIdx : -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()}`; fetchFolderStats(sf.full).then(stats => {
_fetchJSONWithTimeout(url, 2500).then(stats => {
if (!stats) return; if (!stats) return;
const foldersCount = Number.isFinite(stats.folders) ? stats.folders : 0; const foldersCount = Number.isFinite(stats.folders) ? stats.folders : 0;
const filesCount = Number.isFinite(stats.files) ? stats.files : 0; const filesCount = Number.isFinite(stats.files) ? stats.files : 0;
const bytes = Number.isFinite(stats.bytes) // Try multiple possible size keys so backend + JS can drift a bit
? stats.bytes let bytes = null;
: (Number.isFinite(stats.sizeBytes) ? stats.sizeBytes : 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 = []; let pieces = [];
if (foldersCount) pieces.push(`${foldersCount} folder${foldersCount === 1 ? "" : "s"}`); if (foldersCount) pieces.push(`${foldersCount} folder${foldersCount === 1 ? "" : "s"}`);
@@ -1395,6 +1442,26 @@ if (iconSpan) {
sizeCell.title = `${countLabel}${bytes != null && bytes >= 0 ? " • " + sizeLabel : ""}`; 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(() => { }).catch(() => {
if (sizeCellIndex >= 0) { if (sizeCellIndex >= 0) {
const sizeCell = tr.cells[sizeCellIndex]; const sizeCell = tr.cells[sizeCellIndex];
@@ -1887,7 +1954,7 @@ export function renderGalleryView(folder, container) {
// thumbnail // thumbnail
let thumbnail; let thumbnail;
if (/\.(jpe?g|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) { if (/\.(jpe?g|png|gif|bmp|webp|ico)$/i.test(file.name)) {
const cacheKey = previewURL; // include folder & file const cacheKey = previewURL; // include folder & file
if (window.imageCache && window.imageCache[cacheKey]) { if (window.imageCache && window.imageCache[cacheKey]) {
thumbnail = `<img thumbnail = `<img
@@ -1931,7 +1998,7 @@ export function renderGalleryView(folder, container) {
galleryHTML += ` galleryHTML += `
<div class="gallery-card" <div class="gallery-card"
data-file-name="${escapeHTML(file.name)}" data-file-name="${escapeHTML(file.name)}"
style="position:relative; border:1px solid #ccc; padding:5px; text-align:center;"> style="position:relative; border-radius: 12px; border:1px solid #ccc; padding:5px; text-align:center;">
<input type="checkbox" <input type="checkbox"
class="file-checkbox" class="file-checkbox"
id="cb-${idSafe}" id="cb-${idSafe}"

View File

@@ -120,7 +120,12 @@ export function openShareModal(file, folder) {
} }
/* -------------------------------- Media modal viewer -------------------------------- */ /* -------------------------------- Media modal viewer -------------------------------- */
const IMG_RE = /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i; // Images that are safe to inline in <img> tags:
const IMG_RE = /\.(jpg|jpeg|png|gif|bmp|webp|ico)$/i;
// SVG handled separately so we *dont* inline it
const SVG_RE = /\.svg$/i;
const VID_RE = /\.(mp4|mkv|webm|mov|ogv)$/i; const VID_RE = /\.(mp4|mkv|webm|mov|ogv)$/i;
const AUD_RE = /\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i; const AUD_RE = /\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i;
const ARCH_RE = /\.(zip|rar|7z|gz|bz2|xz|tar)$/i; const ARCH_RE = /\.(zip|rar|7z|gz|bz2|xz|tar)$/i;
@@ -422,11 +427,19 @@ export function previewFile(fileUrl, fileName) {
const folder = window.currentFolder || 'root'; const folder = window.currentFolder || 'root';
const name = fileName; const name = fileName;
const lower = (name || '').toLowerCase(); const lower = (name || '').toLowerCase();
const isSvg = SVG_RE.test(lower);
const isImage = IMG_RE.test(lower); const isImage = IMG_RE.test(lower);
const isVideo = VID_RE.test(lower); const isVideo = VID_RE.test(lower);
const isAudio = AUD_RE.test(lower); const isAudio = AUD_RE.test(lower);
setTitle(overlay, name); setTitle(overlay, name);
if (isSvg) {
container.textContent =
t("svg_preview_disabled") ||
"SVG preview is disabled for security. Use Download to view this file.";
overlay.style.display = "flex";
return;
}
/* -------------------- IMAGES -------------------- */ /* -------------------- IMAGES -------------------- */
if (isImage) { if (isImage) {

View File

@@ -342,7 +342,8 @@ const translations = {
"owner": "Owner", "owner": "Owner",
"hide_header_zoom_controls": "Hide header zoom controls", "hide_header_zoom_controls": "Hide header zoom controls",
"preview_not_available": "Preview is not available for this file type.", "preview_not_available": "Preview is not available for this file type.",
"storage_pro_bundle_outdated": "Please upgrade to the latest FileRise Pro bundle to use the Storage explorer." "storage_pro_bundle_outdated": "Please upgrade to the latest FileRise Pro bundle to use the Storage explorer.",
"svg_preview_disabled": "SVG preview is disabled for now for security reasons."
}, },
es: { es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.", "please_log_in_to_continue": "Por favor, inicie sesión para continuar.",

View File

@@ -1,2 +1,2 @@
// generated by CI // generated by CI
window.APP_VERSION = 'v2.2.1'; window.APP_VERSION = 'v2.2.3';

View File

@@ -503,13 +503,13 @@ class FileModel {
if (!preg_match(REGEX_FILE_NAME, $file)) { if (!preg_match(REGEX_FILE_NAME, $file)) {
return ["error" => "Invalid file name."]; return ["error" => "Invalid file name."];
} }
// Determine the real upload directory. // Determine the real upload directory.
$uploadDirReal = realpath(UPLOAD_DIR); $uploadDirReal = realpath(UPLOAD_DIR);
if ($uploadDirReal === false) { if ($uploadDirReal === false) {
return ["error" => "Server misconfiguration."]; return ["error" => "Server misconfiguration."];
} }
// Determine directory based on folder. // Determine directory based on folder.
if (strtolower($folder) === 'root' || trim($folder) === '') { if (strtolower($folder) === 'root' || trim($folder) === '') {
$directory = $uploadDirReal; $directory = $uploadDirReal;
@@ -524,11 +524,11 @@ class FileModel {
return ["error" => "Invalid folder path."]; return ["error" => "Invalid folder path."];
} }
} }
// Build the file path. // Build the file path.
$filePath = $directory . DIRECTORY_SEPARATOR . $file; $filePath = $directory . DIRECTORY_SEPARATOR . $file;
$realFilePath = realpath($filePath); $realFilePath = realpath($filePath);
// Ensure the file exists and is within the allowed directory. // Ensure the file exists and is within the allowed directory.
if ($realFilePath === false || strpos($realFilePath, $uploadDirReal) !== 0) { if ($realFilePath === false || strpos($realFilePath, $uploadDirReal) !== 0) {
return ["error" => "Access forbidden."]; return ["error" => "Access forbidden."];
@@ -536,13 +536,19 @@ class FileModel {
if (!file_exists($realFilePath)) { if (!file_exists($realFilePath)) {
return ["error" => "File not found."]; return ["error" => "File not found."];
} }
// Get the MIME type with safe fallback. // Get the MIME type with safe fallback.
$mimeType = function_exists('mime_content_type') ? mime_content_type($realFilePath) : null; $mimeType = function_exists('mime_content_type') ? mime_content_type($realFilePath) : null;
if (!$mimeType) { if (!$mimeType) {
$mimeType = 'application/octet-stream'; $mimeType = 'application/octet-stream';
} }
// OPTIONAL: normalize SVG MIME
$ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION));
if ($ext === 'svg') {
$mimeType = 'image/svg+xml';
}
return [ return [
"filePath" => $realFilePath, "filePath" => $realFilePath,
"mimeType" => $mimeType "mimeType" => $mimeType

View File

@@ -12,110 +12,135 @@ class FolderModel
* ============================================================ */ * ============================================================ */
public static function countVisible(string $folder, string $user, array $perms): array public static function countVisible(string $folder, string $user, array $perms): array
{ {
$folder = ACL::normalizeFolder($folder); $folder = ACL::normalizeFolder($folder);
// If the user can't view this folder at all, short-circuit (admin/read/read_own) // If the user can't view this folder at all, short-circuit (admin/read/read_own)
$canViewFolder = ACL::isAdmin($perms) $canViewFolder = ACL::isAdmin($perms)
|| ACL::canRead($user, $perms, $folder) || ACL::canRead($user, $perms, $folder)
|| ACL::canReadOwn($user, $perms, $folder); || ACL::canReadOwn($user, $perms, $folder);
if (!$canViewFolder) { if (!$canViewFolder) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0]; return ['folders' => 0, 'files' => 0, 'bytes' => 0];
} }
// NEW: distinguish full read vs own-only for this folder // NEW: distinguish full read vs own-only for this folder
$hasFullRead = ACL::isAdmin($perms) || ACL::canRead($user, $perms, $folder); $hasFullRead = ACL::isAdmin($perms) || ACL::canRead($user, $perms, $folder);
// if !$hasFullRead but $canViewFolder is true, theyre effectively "view own" only // if !$hasFullRead but $canViewFolder is true, theyre effectively "view own" only
$base = realpath((string)UPLOAD_DIR); $base = realpath((string)UPLOAD_DIR);
if ($base === false) { if ($base === false) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0]; return ['folders' => 0, 'files' => 0, 'bytes' => 0];
} }
// Resolve target dir + ACL-relative prefix // Resolve target dir + ACL-relative prefix
if ($folder === 'root') { if ($folder === 'root') {
$dir = $base; $dir = $base;
$relPrefix = ''; $relPrefix = '';
} else { } else {
$parts = array_filter(explode('/', $folder), fn($p) => $p !== ''); $parts = array_filter(explode('/', $folder), fn($p) => $p !== '');
foreach ($parts as $seg) { foreach ($parts as $seg) {
if (!self::isSafeSegment($seg)) { if (!self::isSafeSegment($seg)) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0]; return ['folders' => 0, 'files' => 0, 'bytes' => 0];
} }
} }
$guess = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts); $guess = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
$dir = self::safeReal($base, $guess); $dir = self::safeReal($base, $guess);
if ($dir === null || !is_dir($dir)) { if ($dir === null || !is_dir($dir)) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0]; return ['folders' => 0, 'files' => 0, 'bytes' => 0];
} }
$relPrefix = implode('/', $parts); $relPrefix = implode('/', $parts);
} }
$IGNORE = ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db']; $IGNORE = ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db'];
$SKIP = ['trash', 'profile_pics']; $SKIP = ['trash', 'profile_pics'];
$entries = @scandir($dir); $entries = @scandir($dir);
if ($entries === false) { if ($entries === false) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0]; return ['folders' => 0, 'files' => 0, 'bytes' => 0];
} }
$folderCount = 0; $folderCount = 0;
$fileCount = 0; $fileCount = 0;
$totalBytes = 0; $totalBytes = 0;
$MAX_SCAN = 4000; // NEW: stats for created / modified
$scanned = 0; $earliestUploaded = null; // min mtime
$latestMtime = null; // max mtime
foreach ($entries as $name) {
if (++$scanned > $MAX_SCAN) { $MAX_SCAN = 4000;
break; $scanned = 0;
}
foreach ($entries as $name) {
if ($name === '.' || $name === '..') continue; if (++$scanned > $MAX_SCAN) {
if ($name[0] === '.') continue; break;
if (in_array($name, $IGNORE, true)) continue; }
if (in_array(strtolower($name), $SKIP, true)) continue;
if (!self::isSafeSegment($name)) continue; if ($name === '.' || $name === '..') continue;
if ($name[0] === '.') continue;
$abs = $dir . DIRECTORY_SEPARATOR . $name; if (in_array($name, $IGNORE, true)) continue;
if (in_array(strtolower($name), $SKIP, true)) continue;
if (@is_dir($abs)) { if (!self::isSafeSegment($name)) continue;
if (@is_link($abs)) {
$safe = self::safeReal($base, $abs); $abs = $dir . DIRECTORY_SEPARATOR . $name;
if ($safe === null || !is_dir($safe)) {
continue; if (@is_dir($abs)) {
} if (@is_link($abs)) {
} $safe = self::safeReal($base, $abs);
if ($safe === null || !is_dir($safe)) {
$childRel = ($relPrefix === '' ? $name : $relPrefix . '/' . $name); continue;
if ( }
ACL::isAdmin($perms) }
|| ACL::canRead($user, $perms, $childRel)
|| ACL::canReadOwn($user, $perms, $childRel) $childRel = ($relPrefix === '' ? $name : $relPrefix . '/' . $name);
) { if (
$folderCount++; ACL::isAdmin($perms)
} || ACL::canRead($user, $perms, $childRel)
} elseif (@is_file($abs)) { || ACL::canReadOwn($user, $perms, $childRel)
// Only count files if the user has full read on *this* folder. ) {
// If theyre view_own-only here, dont leak or mis-report counts. $folderCount++;
if (!$hasFullRead) { }
continue; } 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.
$fileCount++; if (!$hasFullRead) {
$sz = @filesize($abs); continue;
if (is_int($sz) && $sz > 0) { }
$totalBytes += $sz;
} $fileCount++;
} $sz = @filesize($abs);
} if (is_int($sz) && $sz > 0) {
$totalBytes += $sz;
return [ }
'folders' => $folderCount,
'files' => $fileCount, // NEW: track earliest / latest mtime from visible files
'bytes' => $totalBytes, $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) */ /* Helpers (private) */
private static function isSafeSegment(string $name): bool private static function isSafeSegment(string $name): bool