diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ddf87f..42a59e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Changes 11/28/2025 (v2.2.1) + +release(v2.2.1): fix(storage-explorer): DOM-safe rendering + docs for disk usage + +- Refactor adminStorage breadcrumb builder to construct DOM nodes instead of using innerHTML. +- Rework Storage explorer folder view to render rows via createElement/textContent, avoiding DOM text reinterpreted as HTML. +- 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 diff --git a/README.md b/README.md index 2e29da2..c36805c 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,10 @@ Drag & drop uploads, ACL-aware sharing, OnlyOffice integration, and a clean UI - 🌳 **Scales to huge trees** – Tested with **100k+ folders** in the sidebar tree. - 🧩 **ONLYOFFICE support (optional)** – Edit DOCX/XLSX/PPTX using your own Document Server. - 🌍 **WebDAV** – Mount FileRise as a drive from macOS, Windows, Linux, or Cyberduck/WinSCP. +- 📊 **Storage / disk usage summary** – CLI scanner with snapshots, total usage, and per-volume breakdowns in the admin panel. - 🎨 **Polished UI** – Dark/light mode, responsive layout, in-browser previews & code editor. - 🔑 **Login + SSO** – Local users, TOTP 2FA, and OIDC (Auth0 / Authentik / Keycloak / etc.). -- 👥 **User groups & client portals (Pro)** – Group-based ACLs and brandable client upload portals. +- 👥 **Pro: user groups, client portals & storage explorer** – Group-based ACLs, brandable client upload portals, and an ncdu-style explorer to drill into folders, largest files, and clean up storage inline. Full list of features available at [Full Feature Wiki](https://github.com/error311/FileRise/wiki/Features) diff --git a/public/js/adminStorage.js b/public/js/adminStorage.js index acc8626..21b7515 100644 --- a/public/js/adminStorage.js +++ b/public/js/adminStorage.js @@ -868,45 +868,67 @@ function setBreadcrumb(folderKey) { const el = document.getElementById('adminStorageBreadcrumb'); if (!el) return; + // Clear existing content safely + while (el.firstChild) { + el.removeChild(el.firstChild); + } + let parts = []; - if (!folderKey || folderKey === 'root') { - parts = []; - } else { + if (folderKey && folderKey !== 'root') { const clean = folderKey.replace(/^\/+|\/+$/g, ''); parts = clean ? clean.split('/') : []; } - const crumbs = []; + // Helper: add a simple separator " / " + const appendSeparator = () => { + const sep = document.createElement('span'); + sep.className = 'mx-1'; + sep.textContent = '/'; + el.appendChild(sep); + }; - // root crumb - crumbs.push(` - - `); + // Root crumb + const rootBtn = document.createElement('button'); + rootBtn.type = 'button'; + rootBtn.className = 'btn btn-link btn-sm p-0 admin-storage-bc'; + rootBtn.dataset.folder = 'root'; + + const rootIcon = document.createElement('span'); + rootIcon.className = 'material-icons'; + rootIcon.style.fontSize = '16px'; + rootIcon.style.verticalAlign = 'middle'; + rootIcon.style.color = 'currentColor'; + rootIcon.textContent = 'home'; + + const rootText = document.createElement('span'); + rootText.style.verticalAlign = 'middle'; + rootText.textContent = tf('storage_root_label', 'root'); + + rootBtn.appendChild(rootIcon); + rootBtn.appendChild(rootText); + el.appendChild(rootBtn); let accum = ''; parts.forEach((p, idx) => { + appendSeparator(); accum = accum ? (accum + '/' + p) : p; const isLast = idx === parts.length - 1; + if (isLast) { - crumbs.push(` - / - ${p} - `); + const span = document.createElement('span'); + span.className = 'fw-semibold'; + span.textContent = p; + el.appendChild(span); } else { - crumbs.push(` - / - - `); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn btn-link btn-sm p-0 admin-storage-bc'; + btn.dataset.folder = accum; + btn.textContent = p; + el.appendChild(btn); } }); - el.innerHTML = crumbs.join(''); - // breadcrumb click handling el.querySelectorAll('.admin-storage-bc').forEach(btn => { btn.addEventListener('click', () => { @@ -952,289 +974,413 @@ function switchExplorerTab(tab) { } async function loadFolderChildren(folderKey) { - currentFolderKey = folderKey || 'root'; - - const inner = document.getElementById('adminStorageExplorerInner'); - if (!inner) return; - - inner.innerHTML = ` -
- ${tf('loading','Loading...')} -
- `; - - setBreadcrumb(currentFolderKey); - - let data; - try { - const url = `/api/pro/diskUsageChildren.php?folder=${encodeURIComponent(currentFolderKey)}`; - const res = await fetch(url, { - credentials: 'include', - cache: 'no-store', - headers: { 'Cache-Control': 'no-store' } - }); - const text = await res.text(); - data = JSON.parse(text || '{}'); - } catch (e) { - console.error('loadFolderChildren error', e); - inner.innerHTML = `
- ${tf('storage_children_error','Please upgrade to the latest FileRise Pro bundle to use the Storage explorer.')} + currentFolderKey = folderKey || 'root'; + + const inner = document.getElementById('adminStorageExplorerInner'); + if (!inner) return; + + inner.innerHTML = ` +
+ ${tf('loading','Loading...')} +
+ `; + + setBreadcrumb(currentFolderKey); + + let data; + try { + const url = `/api/pro/diskUsageChildren.php?folder=${encodeURIComponent(currentFolderKey)}`; + const res = await fetch(url, { + credentials: 'include', + cache: 'no-store', + headers: { 'Cache-Control': 'no-store' } + }); + const text = await res.text(); + data = JSON.parse(text || '{}'); + } catch (e) { + console.error('loadFolderChildren error', e); + inner.innerHTML = `
+ ${tf('storage_children_error','Please upgrade to the latest FileRise Pro bundle to use the Storage explorer.')} +
`; + return; + } + + if (!data || !data.ok) { + if (data && data.error === 'no_snapshot') { + inner.innerHTML = `
+ ${tf('storage_no_snapshot','No disk usage snapshot found. Run the disk usage scan first.')}
`; - return; - } - - if (!data || !data.ok) { - if (data && data.error === 'no_snapshot') { - inner.innerHTML = `
- ${tf('storage_no_snapshot','No disk usage snapshot found. Run the disk usage scan first.')} -
`; - } else { - // Special-case: backend missing ProDiskUsage / outdated Pro bundle - let msgKey = 'storage_children_error'; - let fallback = 'Please upgrade to the latest FileRise Pro bundle to use the Storage explorer.'; - - if ( - data && - data.error === 'internal_error' && - data.message && - /ProDiskUsage/i.test(String(data.message)) - ) { - msgKey = 'storage_pro_bundle_outdated'; - fallback = 'Please upgrade to the latest FileRise Pro bundle to use the Storage explorer.'; - } - - inner.innerHTML = `
- ${tf(msgKey, fallback)} -
`; - } - return; + } else { + // Special-case: backend missing ProDiskUsage / outdated Pro bundle + let msgKey = 'storage_children_error'; + let fallback = 'Please upgrade to the latest FileRise Pro bundle to use the Storage explorer.'; + + if ( + data && + data.error === 'internal_error' && + data.message && + /ProDiskUsage/i.test(String(data.message)) + ) { + msgKey = 'storage_pro_bundle_outdated'; + fallback = 'Please upgrade to the latest FileRise Pro bundle to use the Storage explorer.'; } - - const folders = Array.isArray(data.folders) ? data.folders : []; - const files = Array.isArray(data.files) ? data.files : []; - - const minBytes = folderMinSizeBytes || 0; - - const filteredFolders = folders.filter(f => Number(f.bytes || 0) >= minBytes); - const filteredFiles = files.filter(f => Number(f.bytes || 0) >= minBytes); - - const rowChunks = []; - - // Folder rows first - filteredFolders.forEach(f => { - const label = f.folder === 'root' ? '/' : `/${f.folder}`; - const pct = f.percentOfTotal || 0; - const width = Math.max(3, Math.min(100, Math.round(pct))); - - rowChunks.push(` - - - folder - ${label} - - - ${formatBytes(f.bytes || 0)} - - - ${pct.toFixed(1)}% - - - ${(f.files || 0).toLocaleString()} - - - ${f.latest_mtime ? formatDate(f.latest_mtime) : ''} - - - - - - `); - }); - - // Then file rows - filteredFiles.forEach(file => { - const pct = file.percentOfTotal || 0; - const width = Math.max(3, Math.min(100, Math.round(pct))); - const folder = file.folder || currentFolderKey; - const displayPath = file.path || (folder === 'root' - ? `/${file.name}` - : `/${folder}/${file.name}`); - - rowChunks.push(` - - - insert_drive_file - ${displayPath} - - - ${formatBytes(file.bytes || 0)} - - - ${pct.toFixed(2)}% - - - - - - ${file.mtime ? formatDate(file.mtime) : ''} - - - - - - `); - }); - - const total = rowChunks.length; - const pageSize = 100; - let shown = Math.min(pageSize, total); - - function renderPage() { - const hasRows = total > 0; - const visibleRows = hasRows - ? rowChunks.slice(0, shown).join('') - : ` - ${tf('storage_no_children','No matching items in this folder (for current filter).')} - `; - - const footer = hasRows && shown < total - ? `
- ${tf('storage_showing','Showing')} ${shown} ${tf('of','of')} ${total} - -
` - : hasRows - ? `
- ${tf('storage_showing_all','Showing all')} ${total} ${tf('items','items')}. -
` - : ''; - - inner.innerHTML = ` -
- - - - - - - - - - - - - ${visibleRows} - -
${tf('name','Name')}${tf('size','Size')}%${tf('files','Files')}${tf('modified','Modified')}
-
- ${footer} - `; - - // Wire folder row clicks (drilldown) - inner.querySelectorAll('.admin-storage-row-folder').forEach(row => { - row.addEventListener('click', e => { - if (e.target.closest('.admin-storage-delete-folder')) return; - const folder = row.getAttribute('data-folder') || 'root'; - currentFolderKey = folder; - loadFolderChildren(folder); - }); - }); - - // Wire delete folder/file - inner.querySelectorAll('.admin-storage-delete-folder').forEach(btn => { - btn.addEventListener('click', async e => { - e.stopPropagation(); - const row = btn.closest('tr'); - if (!row) return; - const folder = row.getAttribute('data-folder') || ''; - if (!folder) return; - - const label = folder === 'root' ? '/' : `/${folder}`; - - if (deepDeleteEnabled) { - // SUPER DANGEROUS: deep delete entire subtree - const ok = await showConfirmDialog({ - title: tf('storage_confirm_deep_delete_folder_title', 'Deep delete folder'), - message: tf( - 'storage_confirm_deep_delete_folder_msg', - `Permanently delete folder ${label} and ALL files and subfolders under it? This cannot be undone.` - ), - confirmLabel: tf('deep_delete_folder','Deep delete') - }); - if (!ok) return; - - await deleteFolderFromInspector(folder, row, { deep: true }); - } else { - // Safe mode: only empty folders, same as existing UI - const ok = await showConfirmDialog({ - title: tf('storage_confirm_delete_folder_title', 'Delete folder'), - message: tf( - 'storage_confirm_delete_folder_msg', - `Delete folder ${label}? If the folder is not empty, deletion will fail.` - ), - confirmLabel: tf('delete_folder','Delete folder') - }); - if (!ok) return; - - await deleteFolderFromInspector(folder, row, { deep: false }); - } - }); - }); - - inner.querySelectorAll('.admin-storage-delete-file').forEach(btn => { - btn.addEventListener('click', async e => { - e.stopPropagation(); - const row = btn.closest('tr'); - if (!row) return; - const folder = row.getAttribute('data-folder') || currentFolderKey || 'root'; - const name = row.getAttribute('data-name') || ''; - if (!name) return; - - const display = folder === 'root' ? `/${name}` : `/${folder}/${name}`; - const ok = await showConfirmDialog({ - title: tf('storage_confirm_delete_file_title', 'Permanently delete file'), - message: tf( - 'storage_confirm_delete_file_msg', - `Permanently delete file ${display}? This bypasses Trash and cannot be undone.` - ), - confirmLabel: tf('delete_file','Delete file') + + inner.innerHTML = `
+ ${tf(msgKey, fallback)} +
`; + } + return; + } + + const folders = Array.isArray(data.folders) ? data.folders : []; + const files = Array.isArray(data.files) ? data.files : []; + + const minBytes = folderMinSizeBytes || 0; + + const filteredFolders = folders.filter(f => Number(f.bytes || 0) >= minBytes); + const filteredFiles = files.filter(f => Number(f.bytes || 0) >= minBytes); + + // Build a unified list for pagination: { kind: 'folder' | 'file', item } + const entries = []; + filteredFolders.forEach(f => entries.push({ kind: 'folder', item: f })); + filteredFiles.forEach(file => entries.push({ kind: 'file', item: file })); + + const total = entries.length; + const pageSize = 100; + let shown = Math.min(pageSize, total); + + function renderPage() { + const hasRows = total > 0; + const slice = hasRows ? entries.slice(0, shown) : []; + + // Clear container + inner.innerHTML = ''; + + // Table wrapper + const wrapper = document.createElement('div'); + wrapper.className = 'table-responsive'; + wrapper.style.maxHeight = '340px'; + wrapper.style.overflow = 'auto'; + + const table = document.createElement('table'); + table.className = 'table table-sm mb-0'; + + // ----- thead ----- + const thead = document.createElement('thead'); + const headRow = document.createElement('tr'); + + const thName = document.createElement('th'); + thName.textContent = tf('name','Name'); + headRow.appendChild(thName); + + const thSize = document.createElement('th'); + thSize.textContent = tf('size','Size'); + headRow.appendChild(thSize); + + const thPct = document.createElement('th'); + thPct.textContent = '%'; + headRow.appendChild(thPct); + + const thFiles = document.createElement('th'); + thFiles.textContent = tf('files','Files'); + headRow.appendChild(thFiles); + + const thMod = document.createElement('th'); + thMod.textContent = tf('modified','Modified'); + headRow.appendChild(thMod); + + const thActions = document.createElement('th'); + thActions.style.width = '1%'; + headRow.appendChild(thActions); + + thead.appendChild(headRow); + table.appendChild(thead); + + // ----- tbody ----- + const tbody = document.createElement('tbody'); + + if (!hasRows) { + const tr = document.createElement('tr'); + const td = document.createElement('td'); + td.colSpan = 6; + td.className = 'text-muted small'; + td.textContent = tf( + 'storage_no_children', + 'No matching items in this folder (for current filter).' + ); + tr.appendChild(td); + tbody.appendChild(tr); + } else { + slice.forEach(entry => { + const { kind, item } = entry; + const tr = document.createElement('tr'); + tr.classList.add('admin-storage-row'); + + if (kind === 'folder') { + // ---- Folder row ---- + tr.classList.add('admin-storage-row-folder'); + tr.dataset.type = 'folder'; + tr.dataset.folder = item.folder; + + const label = item.folder === 'root' ? '/' : `/${item.folder}`; + const pct = item.percentOfTotal || 0; + + // Name cell + const tdName = document.createElement('td'); + tdName.className = 'align-middle'; + + const icon = document.createElement('i'); + icon.className = 'material-icons'; + icon.style.fontSize = '16px'; + icon.style.verticalAlign = 'middle'; + icon.style.color = 'currentColor'; + icon.textContent = 'folder'; + + const span = document.createElement('span'); + span.className = 'ms-1 align-middle'; + span.textContent = label; + + tdName.appendChild(icon); + tdName.appendChild(span); + tr.appendChild(tdName); + + // Size + const tdSize = document.createElement('td'); + tdSize.className = 'align-middle text-nowrap'; + tdSize.textContent = formatBytes(item.bytes || 0); + tr.appendChild(tdSize); + + // Percent + const tdPct = document.createElement('td'); + tdPct.className = 'align-middle text-nowrap'; + tdPct.textContent = `${pct.toFixed(1)}%`; + tr.appendChild(tdPct); + + // Files count + const tdFiles = document.createElement('td'); + tdFiles.className = 'align-middle text-nowrap'; + tdFiles.textContent = (item.files || 0).toLocaleString(); + tr.appendChild(tdFiles); + + // Modified + const tdMod = document.createElement('td'); + tdMod.className = 'align-middle text-nowrap'; + tdMod.textContent = item.latest_mtime ? formatDate(item.latest_mtime) : ''; + tr.appendChild(tdMod); + + // Actions + const tdActions = document.createElement('td'); + tdActions.className = 'align-middle'; + tdActions.style.width = '1%'; + tdActions.style.whiteSpace = 'nowrap'; + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn btn-sm btn-outline-danger admin-storage-delete-folder'; + btn.title = tf('delete_folder','Delete folder'); + + const delIcon = document.createElement('i'); + delIcon.className = 'material-icons'; + delIcon.style.fontSize = '16px'; + delIcon.style.color = 'currentColor'; + delIcon.textContent = 'delete'; + + btn.appendChild(delIcon); + tdActions.appendChild(btn); + tr.appendChild(tdActions); + + // Folder row click: drilldown + tr.addEventListener('click', e => { + if (e.target.closest('.admin-storage-delete-folder')) return; + const folder = tr.getAttribute('data-folder') || 'root'; + currentFolderKey = folder; + loadFolderChildren(folder); }); - if (!ok) return; - - await deleteFileFromInspectorPermanent(folder, name, row); - }); + + // Delete folder click + btn.addEventListener('click', async e => { + e.stopPropagation(); + const folder = tr.getAttribute('data-folder') || ''; + if (!folder) return; + + const labelDisp = folder === 'root' ? '/' : `/${folder}`; + + if (deepDeleteEnabled) { + // SUPER DANGEROUS: deep delete entire subtree + const ok = await showConfirmDialog({ + title: tf('storage_confirm_deep_delete_folder_title', 'Deep delete folder'), + message: tf( + 'storage_confirm_deep_delete_folder_msg', + `Permanently delete folder ${labelDisp} and ALL files and subfolders under it? This cannot be undone.` + ), + confirmLabel: tf('deep_delete_folder','Deep delete') + }); + if (!ok) return; + + await deleteFolderFromInspector(folder, tr, { deep: true }); + } else { + // Safe mode: only empty folders, same as existing UI + const ok = await showConfirmDialog({ + title: tf('storage_confirm_delete_folder_title', 'Delete folder'), + message: tf( + 'storage_confirm_delete_folder_msg', + `Delete folder ${labelDisp}? If the folder is not empty, deletion will fail.` + ), + confirmLabel: tf('delete_folder','Delete folder') + }); + if (!ok) return; + + await deleteFolderFromInspector(folder, tr, { deep: false }); + } + }); + } else { + // ---- File row ---- + tr.classList.add('admin-storage-row-file'); + tr.dataset.type = 'file'; + + const folder = item.folder || currentFolderKey; + const displayPath = item.path || (folder === 'root' + ? `/${item.name}` + : `/${folder}/${item.name}`); + const pct = item.percentOfTotal || 0; + + tr.dataset.folder = folder; + tr.dataset.name = item.name; + + // Name cell + const tdName = document.createElement('td'); + tdName.className = 'align-middle'; + + const icon = document.createElement('i'); + icon.className = 'material-icons'; + icon.style.fontSize = '16px'; + icon.style.verticalAlign = 'middle'; + icon.style.color = 'currentColor'; + icon.textContent = 'insert_drive_file'; + + const span = document.createElement('span'); + span.className = 'ms-1 align-middle'; + + const code = document.createElement('code'); + code.textContent = displayPath; + + span.appendChild(code); + tdName.appendChild(icon); + tdName.appendChild(span); + tr.appendChild(tdName); + + // Size + const tdSize = document.createElement('td'); + tdSize.className = 'align-middle text-nowrap'; + tdSize.textContent = formatBytes(item.bytes || 0); + tr.appendChild(tdSize); + + // Percent + const tdPct = document.createElement('td'); + tdPct.className = 'align-middle text-nowrap'; + tdPct.textContent = `${pct.toFixed(2)}%`; + tr.appendChild(tdPct); + + // Files (blank for files) + const tdFiles = document.createElement('td'); + tdFiles.className = 'align-middle text-nowrap'; + tdFiles.textContent = ''; + tr.appendChild(tdFiles); + + // Modified + const tdMod = document.createElement('td'); + tdMod.className = 'align-middle text-nowrap'; + tdMod.textContent = item.mtime ? formatDate(item.mtime) : ''; + tr.appendChild(tdMod); + + // Actions + const tdActions = document.createElement('td'); + tdActions.className = 'align-middle'; + tdActions.style.width = '1%'; + tdActions.style.whiteSpace = 'nowrap'; + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn btn-sm btn-outline-danger admin-storage-delete-file'; + btn.title = tf('delete_file','Delete file'); + + const delIcon = document.createElement('i'); + delIcon.className = 'material-icons'; + delIcon.style.fontSize = '16px'; + delIcon.style.color = 'currentColor'; + delIcon.textContent = 'delete'; + + btn.appendChild(delIcon); + tdActions.appendChild(btn); + tr.appendChild(tdActions); + + btn.addEventListener('click', async e => { + e.stopPropagation(); + const f = tr.getAttribute('data-folder') || currentFolderKey || 'root'; + const name = tr.getAttribute('data-name') || ''; + if (!name) return; + + const display = f === 'root' ? `/${name}` : `/${f}/${name}`; + const ok = await showConfirmDialog({ + title: tf('storage_confirm_delete_file_title', 'Permanently delete file'), + message: tf( + 'storage_confirm_delete_file_msg', + `Permanently delete file ${display}? This bypasses Trash and cannot be undone.` + ), + confirmLabel: tf('delete_file','Delete file') + }); + if (!ok) return; + + await deleteFileFromInspectorPermanent(f, name, tr); + }); + } + + tbody.appendChild(tr); }); - - const moreBtn = inner.querySelector('#adminStorageMoreFolder'); - if (moreBtn) { + } + + table.appendChild(tbody); + wrapper.appendChild(table); + inner.appendChild(wrapper); + + // ----- Footer: showing X of Y / Load more ----- + if (hasRows) { + if (shown < total) { + const footer = document.createElement('div'); + footer.className = 'd-flex justify-content-between align-items-center mt-1 small'; + + const span = document.createElement('span'); + span.textContent = `${tf('storage_showing','Showing')} ${shown} ${tf('of','of')} ${total}`; + footer.appendChild(span); + + const moreBtn = document.createElement('button'); + moreBtn.type = 'button'; + moreBtn.className = 'btn btn-sm btn-outline-secondary'; + moreBtn.id = 'adminStorageMoreFolder'; + moreBtn.textContent = tf('storage_load_more','Load more'); + moreBtn.addEventListener('click', () => { shown = Math.min(shown + pageSize, total); renderPage(); }); + + footer.appendChild(moreBtn); + inner.appendChild(footer); + } else { + const footer = document.createElement('div'); + footer.className = 'mt-1 small text-muted text-end pe-2'; + footer.textContent = `${tf('storage_showing_all','Showing all')} ${total} ${tf('items','items')}.`; + inner.appendChild(footer); } } - - renderPage(); + + // Sync button styles with deep delete toggle + updateDeleteButtonsForDeepDelete(); } + renderPage(); +} + async function loadTopFiles() { const inner = document.getElementById('adminStorageExplorerInner'); if (!inner) return;