diff --git a/CHANGELOG.md b/CHANGELOG.md index 3474b88..aced9c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ # Changelog +## Changes 11/9/2025 (v1.9.0) + +release(v1.9.0): folder tree UX overhaul, fast ACL-aware counts, and .htaccess hardening + +feat(ui): modern folder tree + +- New crisp folder SVG with clear paper insert; unified yellow/orange palette for light & dark +- Proper ARIA tree semantics (role=treeitem, aria-expanded), cleaner chevrons, better alignment +- Breadcrumb tweaks (› separators), hover/selected polish +- Prime icons locally, then confirm via counts for accurate “empty vs non-empty” + +feat(api): add /api/folder/isEmpty.php via controller/model + +- public/api/folder/isEmpty.php delegates to FolderController::stats() +- FolderModel::countVisible() enforces ACL, path safety, and short-circuits after first entry +- Releases PHP session lock early to avoid parallel-request pileups + +perf: cap concurrent “isEmpty” requests + timeouts + +- Small concurrency limiter + fetch timeouts +- In-memory result & inflight caches for fewer network hits + +fix(state): preserve user expand/collapse choices + +- Respect saved folderTreeState; don’t auto-expand unopened nodes +- Only show ancestors for visibility when navigating (no unwanted persists) + +security: tighten .htaccess while enabling WebDAV + +- Deny direct PHP except /api/*.php, /api.php, and /webdav.php +- AcceptPathInfo On; keep path-aware dotfile denial + +refactor: move count logic to model; thin controller action + +chore(css): add unified “folder tree” block with variables (sizes, gaps, colors) + +Files touched: FolderModel.php, FolderController.php, public/js/folderManager.js, public/css/styles.css, public/api/folder/isEmpty.php (new), public/.htaccess + +--- + ## Changes 11/8/2025 (v1.8.13) release(v1.8.13): ui(dnd): stabilize zones, lock sidebar width, and keep header dock in sync diff --git a/public/.htaccess b/public/.htaccess index 0a98d29..704b676 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -4,6 +4,9 @@ Options -Indexes -Multiviews DirectoryIndex index.html +# Allow PATH_INFO for routes like /webdav.php/foo/bar +AcceptPathInfo On + # ---------------- Security: dotfiles ---------------- # Block direct access to dotfiles like .env, .gitignore, etc. @@ -24,10 +27,14 @@ RewriteRule - - [L] # Prevents requests like /.env, /.git/config, /.ssh/id_rsa, etc. RewriteRule "(^|/)\.(?!well-known/)" - [F] -# 2) Deny direct access to PHP outside /api/ -# This stops scanners from hitting /index.php, /admin.php, /wso.php, etc. -RewriteCond %{REQUEST_URI} !^/api/ -RewriteRule \.php$ - [F] +# 2) Deny direct access to PHP except the API endpoints and WebDAV front controller +# - allow /api/*.php (API endpoints) +# - allow /api.php (ReDoc/spec page) +# - allow /webdav.php (SabreDAV front) +RewriteCond %{REQUEST_URI} !^/api/ [NC] +RewriteCond %{REQUEST_URI} !^/api\.php$ [NC] +RewriteCond %{REQUEST_URI} !^/webdav\.php$ [NC] +RewriteRule \.php$ - [F,L] # 3) Never redirect local/dev hosts RewriteCond %{HTTP_HOST} ^(localhost|127\.0\.0\.1|fr\.local|192\.168\.[0-9]+\.[0-9]+)$ [NC] diff --git a/public/api/folder/isEmpty.php b/public/api/folder/isEmpty.php new file mode 100644 index 0000000..cd3c27d --- /dev/null +++ b/public/api/folder/isEmpty.php @@ -0,0 +1,30 @@ + $_SESSION['role'] ?? null, + 'admin' => $_SESSION['admin'] ?? null, + 'isAdmin' => $_SESSION['isAdmin'] ?? null, +]; +@session_write_close(); + +// Input +$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root'; +$folder = str_replace('\\', '/', trim($folder)); +$folder = ($folder === '' || $folder === 'root') ? 'root' : trim($folder, '/'); + +// Delegate to controller (model handles ACL + path safety) +$result = FolderController::stats($folder, $user, $perms); + +// Always return a compact JSON object like before +echo json_encode([ + 'folders' => (int)($result['folders'] ?? 0), + 'files' => (int)($result['files'] ?? 0), +]); \ No newline at end of file diff --git a/public/css/styles.css b/public/css/styles.css index b302b27..43390a8 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -1132,7 +1132,7 @@ body { border-radius: 4px; }.folder-tree { list-style-type: none; - padding-left: 10px; + padding-left: 5px; margin: 0; }.folder-tree.collapsed { display: none; @@ -1149,7 +1149,7 @@ body { text-align: right; }.folder-indent-placeholder { display: inline-block; - width: 30px; + width: 5px; }#folderTreeContainer { display: block; }.folder-option { @@ -1955,4 +1955,171 @@ body { text-overflow: ellipsis; } -#downloadProgressBarOuter { height: 10px; } \ No newline at end of file +#downloadProgressBarOuter { height: 10px; } + +/* ===== FileRise Folder Tree: unified, crisp, aligned ===== */ + +/* Knobs (size, spacing, colors) */ +#folderTreeContainer { + /* Colors (used in BOTH themes) */ + --filr-folder-front: #f6b84e; /* front/lip */ + --filr-folder-back: #ffd36e; /* back body */ + --filr-folder-stroke:#a87312; /* outline */ + --filr-paper-fill: #ffffff; /* paper */ + --filr-paper-stroke: #b2c2db; /* paper edges/lines */ + + /* Size & spacing */ + --row-h: 28px; /* row height */ + --twisty: 24px; /* chevron hit-area size */ + --twisty-gap: -5px; /* gap between chevron and row content */ + --icon-size: 24px; /* 22–26 look good */ + --icon-gap: 6px; /* space between icon and label */ + --indent: 10px; /* subtree indent */ +} + +/* Keep the same yellow/orange in dark mode; boost paper contrast a touch */ +.dark-mode #folderTreeContainer { + --filr-folder-front: #f6b84e; + --filr-folder-back: #ffd36e; + --filr-folder-stroke:#a87312; + --filr-paper-fill: #ffffff; + --filr-paper-stroke: #d0def7; /* brighter so it pops on dark */ +} + +#folderTreeContainer .folder-item { position: static; padding-left: 0; } + +/* visible “row” for each node */ +#folderTreeContainer .folder-row { + position: relative; + display: flex; + align-items: center; + height: var(--row-h); + padding-left: calc(var(--twisty) + var(--twisty-gap)); +} + +/* children indent */ +#folderTreeContainer .folder-item > .folder-tree { margin-left: var(--indent); } + +/* ---------- Chevron toggle (twisty) ---------- */ + +#folderTreeContainer .folder-row > button.folder-toggle { + position: absolute; left: 0; top: 50%; transform: translateY(-50%); + width: var(--twisty); height: var(--twisty); + display: inline-flex; align-items: center; justify-content: center; + border: 1px solid transparent; border-radius: 6px; + background: transparent; cursor: pointer; +} + +#folderTreeContainer .folder-row > button.folder-toggle::before { + content: "▸"; /* closed */ + font-size: calc(var(--twisty) * 0.8); + line-height: 1; +} + +#folderTreeContainer li[role="treeitem"][aria-expanded="true"] + > .folder-row > button.folder-toggle::before { content: "▾"; } + +/* root row (it's a
) */ +#rootRow[aria-expanded="true"] > button.folder-toggle::before { content: "▾"; } + +#folderTreeContainer .folder-row > button.folder-toggle:hover { + border-color: color-mix(in srgb, #7ab3ff 35%, transparent); +} + +/* spacer for leaves so labels align with parents that have a button */ +#folderTreeContainer .folder-row > .folder-spacer { + position: absolute; left: 0; top: 50%; transform: translateY(-50%); + width: var(--twisty); height: var(--twisty); display: inline-block; +} + +#folderTreeContainer .folder-option { + display: inline-flex; + align-items: center; + height: var(--row-h); + line-height: 1.2; /* avoids baseline weirdness */ + padding: 0 8px; + border-radius: 8px; + user-select: none; + white-space: nowrap; + max-width: 100%; + gap: var(--icon-gap); +} + +#folderTreeContainer .folder-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transform: translateY(0.5px); /* tiny optical nudge for text */ +} + +/* ---------- Icon box (size & alignment) ---------- */ + +#folderTreeContainer .folder-icon { + flex: 0 0 var(--icon-size); + width: var(--icon-size); + height: var(--icon-size); + display: inline-flex; + align-items: center; + justify-content: center; + transform: translateY(0.5px); /* tiny optical nudge for SVG */ +} + +#folderTreeContainer .folder-icon svg { + width: 100%; + height: 100%; + display: block; + shape-rendering: geometricPrecision; +} + +/* ---------- Crisp colors & strokes for the SVG parts ---------- */ + +#folderTreeContainer .folder-icon .folder-front, +#folderTreeContainer .folder-icon .folder-back { + fill: currentColor; + stroke: var(--filr-folder-stroke); + stroke-width: 1.1; + vector-effect: non-scaling-stroke; + paint-order: stroke fill; +} + +#folderTreeContainer .folder-icon .folder-front { color: var(--filr-folder-front); } +#folderTreeContainer .folder-icon .folder-back { color: var(--filr-folder-back); } + +#folderTreeContainer .folder-icon .paper { + fill: var(--filr-paper-fill); + stroke: var(--filr-paper-stroke); + stroke-width: 1.5; /* thick so it reads at 24px */ + paint-order: stroke fill; +} + +#folderTreeContainer .folder-icon .paper-fold { + fill: var(--filr-paper-stroke); +} + +#folderTreeContainer .folder-icon .paper-line { + stroke: var(--filr-paper-stroke); + stroke-width: 1.5; + stroke-linecap: round; + fill: none; + opacity: 0.95; +} + +/* subtle highlight along lip to add depth */ +#folderTreeContainer .folder-icon .lip-highlight { + stroke: #ffffff; + stroke-opacity: .35; + stroke-width: 0.9; + fill: none; + vector-effect: non-scaling-stroke; +} + +/* ---------- Hover / Selected ---------- */ + +#folderTreeContainer .folder-option:hover { + background: rgba(122,179,255,.14); +} + +#folderTreeContainer .folder-option.selected { + background: rgba(122,179,255,.24); + box-shadow: inset 0 0 0 1px rgba(122,179,255,.45); +} diff --git a/public/js/folderManager.js b/public/js/folderManager.js index 4b0a291..2d2bcf7 100644 --- a/public/js/folderManager.js +++ b/public/js/folderManager.js @@ -86,7 +86,7 @@ export function getParentFolder(folder) { Breadcrumb Functions ----------------------*/ - function setControlEnabled(el, enabled) { +function setControlEnabled(el, enabled) { if (!el) return; if ('disabled' in el) el.disabled = !enabled; el.classList.toggle('disabled', !enabled); @@ -101,7 +101,7 @@ async function applyFolderCapabilities(folder) { const caps = await res.json(); window.currentFolderCaps = caps; - const isRoot = (folder === 'root'); + const isRoot = (folder === 'root'); setControlEnabled(document.getElementById('createFolderBtn'), !!caps.canCreate); setControlEnabled(document.getElementById('moveFolderBtn'), !!caps.canMoveFolder); setControlEnabled(document.getElementById('renameFolderBtn'), !isRoot && !!caps.canRename); @@ -143,7 +143,7 @@ function breadcrumbClickHandler(e) { updateBreadcrumbTitle(folder); applyFolderCapabilities(folder); - expandTreePath(folder); + expandTreePath(folder, { persist: false, includeLeaf: false }); document.querySelectorAll(".folder-option").forEach(el => el.classList.remove("selected")); const target = document.querySelector(`.folder-option[data-folder="${folder}"]`); if (target) target.classList.add("selected"); @@ -184,7 +184,7 @@ function breadcrumbDropHandler(e) { /* FOLDER MOVE FALLBACK */ if (!dragData) { const plain = (event.dataTransfer && event.dataTransfer.getData("application/x-filerise-folder")) || - (event.dataTransfer && event.dataTransfer.getData("text/plain")) || ""; + (event.dataTransfer && event.dataTransfer.getData("text/plain")) || ""; if (plain) { const sourceFolder = String(plain).trim(); if (sourceFolder && sourceFolder !== "root") { @@ -208,7 +208,7 @@ function breadcrumbDropHandler(e) { window.currentFolder = newPath; } return loadFolderTree().then(() => { - try { expandTreePath(window.currentFolder || "root"); } catch (_) {} + try { expandTreePath(window.currentFolder || "root", { persist: false, includeLeaf: false }); } catch (_) { } loadFileList(window.currentFolder || "root"); }); } else { @@ -268,8 +268,8 @@ async function checkUserFolderPermission() { const isFolderOnly = !!(permissionsData && - permissionsData[username] && - permissionsData[username].folderOnly); + permissionsData[username] && + permissionsData[username].folderOnly); window.userFolderOnly = isFolderOnly; localStorage.setItem("folderOnly", isFolderOnly ? "true" : "false"); @@ -287,59 +287,217 @@ async function checkUserFolderPermission() { } } +// ---------------- SVG icons + icon helpers ---------------- +const _nonEmptyCache = new Map(); + +/** Return inline SVG string for either an empty folder or folder-with-paper */ +/* ---------------------- + Folder icon (SVG + fetch + cache) +----------------------*/ + +// Crisp emoji-like folder (empty / with paper) +function folderSVG(kind = 'empty') { + return ` + `; +} + +const _folderCountCache = new Map(); +const _inflightCounts = new Map(); + +// --- tiny fetch helper with timeout +function fetchJSONWithTimeout(url, ms = 3000) { + const ctrl = new AbortController(); + const tid = setTimeout(() => ctrl.abort(), ms); + return fetch(url, { credentials: 'include', signal: ctrl.signal }) + .then(r => r.ok ? r.json() : { folders: 0, files: 0 }) + .catch(() => ({ folders: 0, files: 0 })) + .finally(() => clearTimeout(tid)); +} + +// --- simple concurrency limiter (prevents 100 simultaneous requests) +const MAX_CONCURRENT_COUNT_REQS = 6; +let _activeCountReqs = 0; +const _countReqQueue = []; + +function _runCount(url) { + return new Promise(resolve => { + const start = () => { + _activeCountReqs++; + fetchJSONWithTimeout(url, 2500) + .then(resolve) + .finally(() => { + _activeCountReqs--; + const next = _countReqQueue.shift(); + if (next) next(); + }); + }; + if (_activeCountReqs < MAX_CONCURRENT_COUNT_REQS) start(); + else _countReqQueue.push(start); + }); +} + +async function fetchFolderCounts(folder) { + if (_folderCountCache.has(folder)) return _folderCountCache.get(folder); + if (_inflightCounts.has(folder)) return _inflightCounts.get(folder); + + const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(folder)}`; + const p = _runCount(url).then(data => { + const result = { + folders: Number(data?.folders || 0), + files: Number(data?.files || 0), + }; + _folderCountCache.set(folder, result); + _inflightCounts.delete(folder); + return result; + }); + + _inflightCounts.set(folder, p); + return p; +} + +function setFolderIconForOption(optEl, kind) { + const iconEl = optEl.querySelector('.folder-icon'); + if (!iconEl) return; + iconEl.dataset.kind = kind; + iconEl.innerHTML = folderSVG(kind); +} + +function ensureFolderIcon(folder) { + const opt = document.querySelector(`.folder-option[data-folder="${CSS.escape(folder)}"]`); + if (!opt) return; + // Set a neutral default first so layout is stable + setFolderIconForOption(opt, 'empty'); + + fetchFolderCounts(folder).then(({ folders, files }) => { + setFolderIconForOption(opt, (folders + files) > 0 ? 'paper' : 'empty'); + }); +} +/** Set a folder row’s icon to 'empty' or 'paper' */ +function setFolderIcon(folderPath, kind) { + const iconEl = document.querySelector(`.folder-option[data-folder="${folderPath}"] .folder-icon`); + if (!iconEl) return; + if (iconEl.dataset.icon === kind) return; + iconEl.dataset.icon = kind; + iconEl.innerHTML = folderSVG(kind); +} + +/** Fast local heuristic: mark 'paper' if we can see any subfolders under this LI */ +function markNonEmptyIfHasChildren(folderPath) { + const option = document.querySelector(`.folder-option[data-folder="${folderPath}"]`); + if (!option) return false; + const li = option.closest('li[role="treeitem"]'); + const childUL = li ? li.querySelector(':scope > ul') : null; + const hasChildNodes = !!(childUL && childUL.querySelector('li')); + if (hasChildNodes) { setFolderIcon(folderPath, 'paper'); _nonEmptyCache.set(folderPath, true); } + return hasChildNodes; +} + +/** ACL-aware check for files: call a tiny stats endpoint (see part C) */ +async function fetchFolderNonEmptyACL(folderPath) { + if (_nonEmptyCache.has(folderPath)) return _nonEmptyCache.get(folderPath); + const { folders, files } = await fetchFolderCounts(folderPath); + const nonEmpty = (folders + files) > 0; + _nonEmptyCache.set(folderPath, nonEmpty); + return nonEmpty; +} + + /* ---------------------- DOM Building Functions for Folder Tree ----------------------*/ function renderFolderTree(tree, parentPath = "", defaultDisplay = "block") { const state = loadFolderTreeState(); - let html = `