Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da14d204a6 | ||
|
|
2a87002e1f | ||
|
|
4b83facc97 | ||
|
|
3e473d57b4 | ||
|
|
f2ce43f18f | ||
|
|
a50fa30db2 | ||
|
|
d6631adc2d |
36
CHANGELOG.md
36
CHANGELOG.md
@@ -1,5 +1,39 @@
|
||||
# Changelog
|
||||
|
||||
## Changes 11/30/2025 (v2.2.4)
|
||||
|
||||
release(v2.2.4): fix(admin): ONLYOFFICE JWT save crash and respect replace/locked flags
|
||||
|
||||
- Prevented a JS crash when the ONLYOFFICE JWT field isn’t present by always initializing payload.onlyoffice before touching jwtSecret.
|
||||
- Tightened ONLYOFFICE JWT handling so the secret is only sent when config isn’t locked by PHP and the admin explicitly chooses Replace (or is setting it for the first time), instead of always pushing whatever is in the field.
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
||||
release(v2.2.1): fix(storage-explorer): DOM-safe rendering + docs for disk usage
|
||||
@@ -9,6 +43,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
|
||||
|
||||
@@ -1942,7 +1942,7 @@ function handleSave() {
|
||||
oidc: {
|
||||
providerUrl: document.getElementById("oidcProviderUrl").value.trim(),
|
||||
redirectUri: document.getElementById("oidcRedirectUri").value.trim(),
|
||||
// clientId/clientSecret: only include when replacing
|
||||
// clientId/clientSecret added conditionally below
|
||||
},
|
||||
globalOtpauthUrl: document.getElementById("globalOtpauthUrl").value.trim(),
|
||||
branding: {
|
||||
@@ -1952,13 +1952,15 @@ function handleSave() {
|
||||
},
|
||||
};
|
||||
|
||||
// --- OIDC extras (unchanged) ---
|
||||
const idEl = document.getElementById("oidcClientId");
|
||||
const scEl = document.getElementById("oidcClientSecret");
|
||||
|
||||
const idVal = idEl?.value.trim() || '';
|
||||
const secVal = scEl?.value.trim() || '';
|
||||
const idFirstTime = idEl && !idEl.hasAttribute('data-replace'); // no saved value yet
|
||||
const secFirstTime = scEl && !scEl.hasAttribute('data-replace'); // no saved value yet
|
||||
const idFirstTime = idEl && !idEl.hasAttribute('data-replace');
|
||||
const secFirstTime = scEl && !scEl.hasAttribute('data-replace');
|
||||
|
||||
if ((idEl?.dataset.replace === '1' || idFirstTime) && idVal !== '') {
|
||||
payload.oidc.clientId = idVal;
|
||||
}
|
||||
@@ -1966,26 +1968,26 @@ function handleSave() {
|
||||
payload.oidc.clientSecret = secVal;
|
||||
}
|
||||
|
||||
// ---- ONLYOFFICE payload ----
|
||||
const ooSecretEl = document.getElementById("ooJwtSecret");
|
||||
|
||||
|
||||
if (ooSecretEl?.dataset.replace === '1' && ooSecretEl.value.trim() !== '') {
|
||||
payload.onlyoffice.jwtSecret = ooSecretEl.value.trim();
|
||||
}
|
||||
|
||||
// ---- ONLYOFFICE payload ----
|
||||
payload.onlyoffice = {
|
||||
enabled: document.getElementById("ooEnabled").checked,
|
||||
docsOrigin: document.getElementById("ooDocsOrigin").value.trim()
|
||||
};
|
||||
|
||||
if (!window.__OO_LOCKED) {
|
||||
const ooSecretVal = (document.getElementById("ooJwtSecret")?.value || "").trim();
|
||||
if (ooSecretVal !== "") {
|
||||
payload.onlyoffice.jwtSecret = ooSecretVal;
|
||||
// Only send JWT secret if NOT locked by PHP and user chose Replace / first-time set
|
||||
if (!window.__OO_LOCKED && ooSecretEl) {
|
||||
const val = ooSecretEl.value.trim();
|
||||
const hasSaved = !!window.__HAS_OO_SECRET; // set in openAdminPanel
|
||||
const shouldReplace = ooSecretEl.dataset.replace === '1' || !hasSaved;
|
||||
|
||||
if (shouldReplace && val !== "") {
|
||||
payload.onlyoffice.jwtSecret = val;
|
||||
}
|
||||
}
|
||||
|
||||
// --- save call (unchanged) ---
|
||||
fetch('/api/admin/updateConfig.php', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
|
||||
@@ -179,9 +179,22 @@ export function buildFileTableRow(file, folderPath) {
|
||||
const safeUploader = escapeHTML(file.uploader || "Unknown");
|
||||
|
||||
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 = "";
|
||||
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>`;
|
||||
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(file.name)) {
|
||||
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)) {
|
||||
previewIcon = `<i class="material-icons">audiotrack</i>`;
|
||||
}
|
||||
previewButton = `<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-info preview-btn"
|
||||
data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
|
||||
data-preview-name="${safeFileName}"
|
||||
title="${t('preview')}">
|
||||
${previewIcon}
|
||||
</button>`;
|
||||
|
||||
previewButton = `
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-info preview-btn"
|
||||
data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
|
||||
data-preview-name="${safeFileName}"
|
||||
title="${t('preview')}">
|
||||
${previewIcon}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
return `
|
||||
@@ -242,13 +257,13 @@ export function buildFileTableRow(file, folderPath) {
|
||||
<i class="material-icons">drive_file_rename_outline</i>
|
||||
</button>
|
||||
<!-- share -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm share-btn ms-1"
|
||||
data-file="${safeFileName}"
|
||||
title="${t('share')}">
|
||||
<i class="material-icons">share</i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm share-btn ms-1"
|
||||
data-file="${safeFileName}"
|
||||
title="${t('share')}">
|
||||
<i class="material-icons">share</i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -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];
|
||||
@@ -1887,7 +1954,7 @@ export function renderGalleryView(folder, container) {
|
||||
|
||||
// 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
|
||||
if (window.imageCache && window.imageCache[cacheKey]) {
|
||||
thumbnail = `<img
|
||||
@@ -1931,7 +1998,7 @@ export function renderGalleryView(folder, container) {
|
||||
galleryHTML += `
|
||||
<div class="gallery-card"
|
||||
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"
|
||||
class="file-checkbox"
|
||||
id="cb-${idSafe}"
|
||||
|
||||
@@ -120,7 +120,12 @@ export function openShareModal(file, folder) {
|
||||
}
|
||||
|
||||
/* -------------------------------- 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 *don’t* inline it
|
||||
const SVG_RE = /\.svg$/i;
|
||||
|
||||
const VID_RE = /\.(mp4|mkv|webm|mov|ogv)$/i;
|
||||
const AUD_RE = /\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/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 name = fileName;
|
||||
const lower = (name || '').toLowerCase();
|
||||
const isSvg = SVG_RE.test(lower);
|
||||
const isImage = IMG_RE.test(lower);
|
||||
const isVideo = VID_RE.test(lower);
|
||||
const isAudio = AUD_RE.test(lower);
|
||||
|
||||
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 -------------------- */
|
||||
if (isImage) {
|
||||
|
||||
@@ -342,7 +342,8 @@ const translations = {
|
||||
"owner": "Owner",
|
||||
"hide_header_zoom_controls": "Hide header zoom controls",
|
||||
"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: {
|
||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// generated by CI
|
||||
window.APP_VERSION = 'v2.2.1';
|
||||
window.APP_VERSION = 'v2.2.4';
|
||||
|
||||
@@ -503,13 +503,13 @@ class FileModel {
|
||||
if (!preg_match(REGEX_FILE_NAME, $file)) {
|
||||
return ["error" => "Invalid file name."];
|
||||
}
|
||||
|
||||
|
||||
// Determine the real upload directory.
|
||||
$uploadDirReal = realpath(UPLOAD_DIR);
|
||||
if ($uploadDirReal === false) {
|
||||
return ["error" => "Server misconfiguration."];
|
||||
}
|
||||
|
||||
|
||||
// Determine directory based on folder.
|
||||
if (strtolower($folder) === 'root' || trim($folder) === '') {
|
||||
$directory = $uploadDirReal;
|
||||
@@ -524,11 +524,11 @@ class FileModel {
|
||||
return ["error" => "Invalid folder path."];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Build the file path.
|
||||
$filePath = $directory . DIRECTORY_SEPARATOR . $file;
|
||||
$filePath = $directory . DIRECTORY_SEPARATOR . $file;
|
||||
$realFilePath = realpath($filePath);
|
||||
|
||||
|
||||
// Ensure the file exists and is within the allowed directory.
|
||||
if ($realFilePath === false || strpos($realFilePath, $uploadDirReal) !== 0) {
|
||||
return ["error" => "Access forbidden."];
|
||||
@@ -536,13 +536,19 @@ class FileModel {
|
||||
if (!file_exists($realFilePath)) {
|
||||
return ["error" => "File not found."];
|
||||
}
|
||||
|
||||
|
||||
// Get the MIME type with safe fallback.
|
||||
$mimeType = function_exists('mime_content_type') ? mime_content_type($realFilePath) : null;
|
||||
if (!$mimeType) {
|
||||
$mimeType = 'application/octet-stream';
|
||||
}
|
||||
|
||||
|
||||
// OPTIONAL: normalize SVG MIME
|
||||
$ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION));
|
||||
if ($ext === 'svg') {
|
||||
$mimeType = 'image/svg+xml';
|
||||
}
|
||||
|
||||
return [
|
||||
"filePath" => $realFilePath,
|
||||
"mimeType" => $mimeType
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user