Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b83facc97 | ||
|
|
3e473d57b4 | ||
|
|
f2ce43f18f | ||
|
|
a50fa30db2 | ||
|
|
d6631adc2d |
27
CHANGELOG.md
27
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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 *don’t* 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) {
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// generated by CI
|
// generated by CI
|
||||||
window.APP_VERSION = 'v2.2.1';
|
window.APP_VERSION = 'v2.2.3';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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, they’re effectively "view own" only
|
// if !$hasFullRead but $canViewFolder is true, they’re 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 they’re view_own-only here, don’t 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 they’re view_own-only here, don’t 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
|
||||||
|
|||||||
Reference in New Issue
Block a user