Compare commits

..

7 Commits

9 changed files with 331 additions and 166 deletions

View File

@@ -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 isnt present by always initializing payload.onlyoffice before touching jwtSecret.
- Tightened ONLYOFFICE JWT handling so the secret is only sent when config isnt 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

View File

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

View File

@@ -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,7 +203,9 @@ 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
previewButton = `
<button
type="button"
class="btn btn-sm btn-info preview-btn"
data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"

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 }) => {
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;
if ((folders + files) > 0 && iconSpan.dataset.kind !== 'paper') {
// 2) swap to "paper" icon
iconSpan.dataset.kind = 'paper';
iconSpan.innerHTML = folderSVG('paper');
// re-apply sizing to this new SVG too
try { syncFolderIconSizeToRowHeight(); } catch {}
}
})
.catch(() => { /* ignore */ });
.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) -----
// ----- 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}"

View File

@@ -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 *dont* 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) {

View File

@@ -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.",

View File

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

View File

@@ -543,6 +543,12 @@ class FileModel {
$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

View File

@@ -63,6 +63,10 @@ class FolderModel
$fileCount = 0;
$totalBytes = 0;
// NEW: stats for created / modified
$earliestUploaded = null; // min mtime
$latestMtime = null; // max mtime
$MAX_SCAN = 4000;
$scanned = 0;
@@ -107,14 +111,35 @@ class FolderModel
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;
}
}
}
}
return [
$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) */