diff --git a/CHANGELOG.md b/CHANGELOG.md index aced9c4..e6ecaa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog +## Changes 11/9/2025 (v1.9.1) + +release(v1.9.1): customizable folder colors + live preview; improved tree persistence; accent button; manual sync script + +### Highlights v1.9.1 + +- 🎨 Per-folder colors with live SVG preview and consistent styling in light/dark modes. +- đź“„ Folder icons auto-refresh when contents change (no full page reload). +- đź§ Drag-and-drop breadcrumb fallback for folder→folder moves. +- 🛠️ Safer upgrade helper script to rsync app files without touching data. + +- feat(colors): add per-folder color customization + - New endpoints: GET /api/folder/getFolderColors.php and POST /api/folder/saveFolderColor.php + - AuthZ: reuse canRename for “customize folder”, validate hex, and write atomically to metadata/folder_colors.json. + - Read endpoint filters map by ACL::canRead before returning to the user. + - Frontend: load/apply colors to tree rows; persist on move/rename; API helpers saveFolderColor/getFolderColors. + +- feat(ui): color-picker modal with live SVG folder preview + - Shows preview that updates as you pick; supports Save/Reset; protects against accidental toggle clicks. + +- feat(controls): “Color folder” button in Folder Management card + - New `.btn-color-folder` with accent palette (#008CB4), hover/active/focus states, dark-mode tuning; event wiring gated by caps. + +- i18n: add strings for color UI (color_folder, choose_color, reset_default, save_color, folder_color_saved, folder_color_cleared). + +- ux(tree): make expansion state more predictable across refreshes + - `expandTreePath(path, {force,persist,includeLeaf})` with persistence; keep ancestors expanded; add click-suppression guard. + +- ux(layout): center the folder-actions toolbar; remove left padding hacks; normalize icon sizing. + +- chore(ops): add scripts/manual-sync.sh (safe rsync update path, preserves data dirs and public/.htaccess). + +--- + ## Changes 11/9/2025 (v1.9.0) release(v1.9.0): folder tree UX overhaul, fast ACL-aware counts, and .htaccess hardening diff --git a/public/api/folder/getFolderColors.php b/public/api/folder/getFolderColors.php new file mode 100644 index 0000000..d6c2bea --- /dev/null +++ b/public/api/folder/getFolderColors.php @@ -0,0 +1,17 @@ +getFolderColors(); // echoes JSON + status codes +} catch (Throwable $e) { + error_log('getFolderColors failed: ' . $e->getMessage()); + http_response_code(500); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['error' => 'Internal server error']); +} \ No newline at end of file diff --git a/public/api/folder/saveFolderColor.php b/public/api/folder/saveFolderColor.php new file mode 100644 index 0000000..4e14808 --- /dev/null +++ b/public/api/folder/saveFolderColor.php @@ -0,0 +1,17 @@ +saveFolderColor(); // validates method + CSRF, does ACL, echoes JSON +} catch (Throwable $e) { + error_log('saveFolderColor failed: ' . $e->getMessage()); + http_response_code(500); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['error' => 'Internal server error']); +} \ No newline at end of file diff --git a/public/css/styles.css b/public/css/styles.css index 43390a8..af86133 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -62,6 +62,51 @@ body { @media (max-width: 600px) { .zones-toggle { left: 85px !important; } } + +/* Optional tokens */ +:root{ + --filr-accent-500:#008CB4; /* base */ + --filr-accent-600:#00789A; /* hover */ + --filr-accent-700:#006882; /* active/border */ + --filr-accent-ring:rgba(0,140,180,.4); +} + +/* Button */ +.btn-color-folder{ + display:inline-flex; align-items:center; gap:6px; + background:var(--filr-accent-500); + border:1px solid var(--filr-accent-700); + color:#fff; /* ensure white text */ +} +.btn-color-folder .material-icons{ + color:currentColor; /* makes icon white too */ +} + +.btn-color-folder:hover, +.btn-color-folder:focus-visible{ + background:var(--filr-accent-600); + border-color:var(--filr-accent-700); +} + +.btn-color-folder:active{ + background:var(--filr-accent-700); +} + +.btn-color-folder:focus-visible{ + outline:2px solid var(--filr-accent-ring); + outline-offset:2px; +} + +/* Dark mode: start slightly deeper so it doesn't glow */ +.dark-mode .btn-color-folder{ + background:var(--filr-accent-600); + border-color:var(--filr-accent-700); + color:#fff; +} +.dark-mode .btn-color-folder:hover, +.dark-mode .btn-color-folder:focus-visible{ + background:var(--filr-accent-700); +} /* =========================================================== HEADER & NAVIGATION =========================================================== */ @@ -801,14 +846,17 @@ body { } #uploadForm { display: none; - }.folder-actions { - display: flex; - flex-wrap: nowrap; - padding-left: 8px; + } + .folder-actions { + display: flex; + justify-content: center; align-items: center; - white-space: nowrap; - padding-top: 10px; - }@media (min-width: 600px) and (max-width: 992px) { + gap: 2px; + flex-wrap: wrap; + white-space: normal; + margin: 0; /* no hacks needed */ + } + @media (min-width: 600px) and (max-width: 992px) { .folder-actions { white-space: nowrap; }} @@ -821,10 +869,8 @@ body { }.folder-actions .material-icons { font-size: 24px; vertical-align: -2px; - }.folder-actions .btn + .btn { - margin-left: 6px; }.folder-actions .btn { - padding: 10px 12px; + font-size: 0.85rem; line-height: 1.1; border-radius: 6px; @@ -834,7 +880,7 @@ body { transition: transform 120ms ease, box-shadow 120ms ease; will-change: transform; }.folder-actions .material-icons { - font-size: 24px; + font-size: 20px; vertical-align: -2px; transition: transform 120ms ease; }.folder-actions .btn:hover, @@ -1152,6 +1198,7 @@ body { width: 5px; }#folderTreeContainer { display: block; + margin-left: 10px; }.folder-option { cursor: pointer; }.folder-option:hover { @@ -1641,6 +1688,7 @@ body { }.custom-folder-card-body { padding-top: 5px !important; padding-right: 0 !important; + padding-left: 0 !important; }#addUserModal, #removeUserModal { z-index: 5000 !important; @@ -2073,17 +2121,6 @@ body { /* ---------- 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); @@ -2123,3 +2160,15 @@ body { background: rgba(122,179,255,.24); box-shadow: inset 0 0 0 1px rgba(122,179,255,.45); } + +/* variables will be set inline per .folder-option when user colors a folder */ +#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); } diff --git a/public/index.html b/public/index.html index 763b5ef..738522b 100644 --- a/public/index.html +++ b/public/index.html @@ -252,6 +252,9 @@ + + palette + share diff --git a/public/js/fileActions.js b/public/js/fileActions.js index 15c50c4..df4bf93 100644 --- a/public/js/fileActions.js +++ b/public/js/fileActions.js @@ -2,6 +2,7 @@ import { showToast, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}'; import { loadFileList } from './fileListView.js?v={{APP_QVER}}'; import { formatFolderName } from './fileListView.js?v={{APP_QVER}}'; +import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}'; import { t } from './i18n.js?v={{APP_QVER}}'; export function handleDeleteSelected(e) { @@ -47,6 +48,7 @@ document.addEventListener("DOMContentLoaded", function () { if (data.success) { showToast("Selected files deleted successfully!"); loadFileList(window.currentFolder); + refreshFolderIcon(window.currentFolder); } else { showToast("Error: " + (data.error || "Could not delete files")); } @@ -129,6 +131,7 @@ export async function handleCreateFile(e) { if (!js.success) throw new Error(js.error); showToast(t('file_created')); loadFileList(folder); + refreshFolderIcon(folder); } catch (err) { showToast(err.message || t('error_creating_file')); } finally { @@ -300,6 +303,7 @@ document.addEventListener("DOMContentLoaded", () => { } showToast(t('file_created_successfully')); loadFileList(window.currentFolder); + refreshFolderIcon(folder); } catch (err) { console.error(err); showToast(err.message || t('error_creating_file')); @@ -633,6 +637,7 @@ document.addEventListener("DOMContentLoaded", function () { if (data.success) { showToast("Selected files copied successfully!", 5000); loadFileList(window.currentFolder); + refreshFolderIcon(targetFolder); } else { showToast("Error: " + (data.error || "Could not copy files"), 5000); } @@ -685,6 +690,8 @@ document.addEventListener("DOMContentLoaded", function () { if (data.success) { showToast("Selected files moved successfully!"); loadFileList(window.currentFolder); + refreshFolderIcon(targetFolder); + refreshFolderIcon(window.currentFolder); } else { showToast("Error: " + (data.error || "Could not move files")); } diff --git a/public/js/folderManager.js b/public/js/folderManager.js index 2d2bcf7..ab3b9e7 100644 --- a/public/js/folderManager.js +++ b/public/js/folderManager.js @@ -74,6 +74,13 @@ function loadFolderTreeState() { function saveFolderTreeState(state) { localStorage.setItem("folderTreeState", JSON.stringify(state)); } +/* ---------------------- + Transient UI guards (click suppression) +----------------------*/ +let _suppressToggleUntil = 0; +function suppressNextToggle(ms = 300) { + _suppressToggleUntil = performance.now() + ms; +} // Helper for getting the parent folder. export function getParentFolder(folder) { @@ -102,9 +109,11 @@ async function applyFolderCapabilities(folder) { window.currentFolderCaps = caps; const isRoot = (folder === 'root'); + setControlEnabled(document.getElementById('createFolderBtn'), !!caps.canCreate); setControlEnabled(document.getElementById('moveFolderBtn'), !!caps.canMoveFolder); setControlEnabled(document.getElementById('renameFolderBtn'), !isRoot && !!caps.canRename); + setControlEnabled(document.getElementById('colorFolderBtn'), !isRoot && !!caps.canRename); setControlEnabled(document.getElementById('deleteFolderBtn'), !isRoot && !!caps.canDelete); setControlEnabled(document.getElementById('shareFolderBtn'), !isRoot && !!caps.canShareFolder); } @@ -174,56 +183,71 @@ function breadcrumbDropHandler(e) { e.preventDefault(); link.classList.remove("drop-hover"); const dropFolder = link.getAttribute("data-folder"); + let dragData; try { dragData = JSON.parse(e.dataTransfer.getData("application/json")); - } catch (err) { - console.error("Invalid drag data on breadcrumb:", err); - return; - } - /* FOLDER MOVE FALLBACK */ + } catch (_) { /* noop */ } + + // FOLDER MOVE FALLBACK (folder->folder) if (!dragData) { - const plain = (event.dataTransfer && event.dataTransfer.getData("application/x-filerise-folder")) || - (event.dataTransfer && event.dataTransfer.getData("text/plain")) || ""; - if (plain) { - const sourceFolder = String(plain).trim(); - if (sourceFolder && sourceFolder !== "root") { - if (dropFolder === sourceFolder || (dropFolder + "/").startsWith(sourceFolder + "/")) { - showToast("Invalid destination.", 4000); - return; - } - fetchWithCsrf("/api/folder/moveFolder.php", { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ source: sourceFolder, destination: dropFolder }) - }) - .then(safeJson) - .then(data => { - if (data && !data.error) { - showToast(`Folder moved to ${dropFolder}!`); - if (window.currentFolder && (window.currentFolder === sourceFolder || window.currentFolder.startsWith(sourceFolder + "/"))) { - const base = sourceFolder.split("/").pop(); - const newPath = (dropFolder === "root" ? "" : dropFolder + "/") + base; - window.currentFolder = newPath; - } - return loadFolderTree().then(() => { - try { expandTreePath(window.currentFolder || "root", { persist: false, includeLeaf: false }); } catch (_) { } - loadFileList(window.currentFolder || "root"); - }); - } else { - showToast("Error: " + (data && data.error || "Could not move folder"), 5000); - } - }) - .catch(err => { - console.error("Error moving folder:", err); - showToast("Error moving folder", 5000); - }); - } + const plain = (e.dataTransfer && e.dataTransfer.getData("application/x-filerise-folder")) || + (e.dataTransfer && e.dataTransfer.getData("text/plain")) || ""; + const sourceFolder = String(plain || "").trim(); + if (!sourceFolder || sourceFolder === "root") return; + + if (dropFolder === sourceFolder || (dropFolder + "/").startsWith(sourceFolder + "/")) { + showToast("Invalid destination.", 4000); + return; } + + fetchWithCsrf("/api/folder/moveFolder.php", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ source: sourceFolder, destination: dropFolder }) + }) + .then(safeJson) + .then(data => { + if (data && !data.error) { + showToast(`Folder moved to ${dropFolder}!`); + // Make icons reflect new emptiness without reload +refreshFolderIcon(dragData.sourceFolder); +refreshFolderIcon(dropFolder); + + if (window.currentFolder && + (window.currentFolder === sourceFolder || window.currentFolder.startsWith(sourceFolder + "/"))) { + const base = sourceFolder.split("/").pop(); + const newPath = (dropFolder === "root" ? "" : dropFolder + "/") + base; + + // carry color without await + const oldColor = window.folderColorMap[sourceFolder]; + if (oldColor) { + saveFolderColor(newPath, oldColor) + .then(() => saveFolderColor(sourceFolder, '')) + .catch(() => { }); + } + + window.currentFolder = newPath; + } + + return loadFolderTree().then(() => { + try { expandTreePath(window.currentFolder || "root", { persist: false, includeLeaf: false }); } catch (_) { } + loadFileList(window.currentFolder || "root"); + }); + } else { + showToast("Error: " + (data && data.error || "Could not move folder"), 5000); + } + }) + .catch(err => { + console.error("Error moving folder:", err); + showToast("Error moving folder", 5000); + }); + return; } + // File(s) drop path (unchanged) const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []); if (filesToMove.length === 0) return; @@ -242,6 +266,8 @@ function breadcrumbDropHandler(e) { if (data.success) { showToast(`File(s) moved successfully to ${dropFolder}!`); loadFileList(dragData.sourceFolder); + refreshFolderIcon(dragData.sourceFolder); + refreshFolderIcon(dropFolder); } else { showToast("Error moving files: " + (data.error || "Unknown error")); } @@ -252,6 +278,230 @@ function breadcrumbDropHandler(e) { }); } +// ---- Folder Colors (state + helpers) ---- +window.folderColorMap = {}; // { "path": "#RRGGBB", ... } + +async function loadFolderColors() { + try { + const r = await fetch('/api/folder/getFolderColors.php', { credentials: 'include' }); + if (!r.ok) return (window.folderColorMap = {}); + window.folderColorMap = await r.json() || {}; + } catch { window.folderColorMap = {}; } +} + +// tiny color utils +function hexToHsl(hex) { + hex = hex.replace('#', ''); + if (hex.length === 3) hex = hex.split('').map(c => c + c).join(''); + const r = parseInt(hex.substr(0, 2), 16) / 255; + const g = parseInt(hex.substr(2, 2), 16) / 255; + const b = parseInt(hex.substr(4, 2), 16) / 255; + const max = Math.max(r, g, b), min = Math.min(r, g, b); + let h, s, l = (max + min) / 2; + if (max === min) { h = s = 0; } + else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: h = (g - b) / d + (g < b ? 6 : 0); break; + case g: h = (b - r) / d + 2; break; + case b: h = (r - g) / d + 4; break; + } + h /= 6; + } + return { h: h * 360, s: s * 100, l: l * 100 }; +} +function hslToHex(h, s, l) { + h /= 360; s /= 100; l /= 100; + const f = n => { + const k = (n + h * 12) % 12, a = s * Math.min(l, 1 - l); + const c = l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1))); + return Math.round(255 * c).toString(16).padStart(2, '0'); + }; + return '#' + f(0) + f(8) + f(4); +} +function lighten(hex, amt = 12) { + const { h, s, l } = hexToHsl(hex); return hslToHex(h, s, Math.min(100, l + amt)); +} +function darken(hex, amt = 18) { + const { h, s, l } = hexToHsl(hex); return hslToHex(h, s, Math.max(0, l - amt)); +} + +function applyFolderColorToOption(folder, hex) { + // accepts folder like "root" or "A/B" + const sel = folder === 'root' + ? '#rootRow .folder-option' + : `.folder-option[data-folder="${CSS.escape(folder)}"]`; + const el = document.querySelector(sel); + if (!el) return; + + if (!hex) { + el.style.removeProperty('--filr-folder-front'); + el.style.removeProperty('--filr-folder-back'); + el.style.removeProperty('--filr-folder-stroke'); + return; + } + + const front = hex; // main + const back = lighten(hex, 14); // body (slightly lighter) + const stroke = darken(hex, 22); // outline + + el.style.setProperty('--filr-folder-front', front); + el.style.setProperty('--filr-folder-back', back); + el.style.setProperty('--filr-folder-stroke', stroke); +} + +function applyAllFolderColors(scope = document) { + Object.entries(window.folderColorMap || {}).forEach(([folder, hex]) => { + applyFolderColorToOption(folder, hex); + }); +} + +async function saveFolderColor(folder, colorHexOrEmpty) { + const res = await fetch('/api/folder/saveFolderColor.php', { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken }, + body: JSON.stringify({ folder, color: colorHexOrEmpty }) + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.error) throw new Error(data.error || `HTTP ${res.status}`); + // update local map & apply + if (data.color) window.folderColorMap[folder] = data.color; + else delete window.folderColorMap[folder]; + applyFolderColorToOption(folder, data.color || ''); + return data; +} + +function openColorFolderModal(folder) { + const existing = window.folderColorMap[folder] || ''; + const defaultHex = existing || '#f6b84e'; + + const modal = document.createElement('div'); + modal.id = 'colorFolderModal'; + modal.className = 'modal'; + modal.innerHTML = ` + + + + + ${t('color_folder')}: ${escapeHTML(folder)} + × + + + + ${t('choose_color')} + + + + + ${folderSVG('paper')} + ${escapeHTML(folder)} + + + + ${t('reset_default')} + ${t('save_color')} + + + + `; + document.body.appendChild(modal); + modal.style.display = 'block'; + + // --- live preview wiring + const previewEl = modal.querySelector('#folderColorPreview'); + const inputEl = modal.querySelector('#folderColorInput'); + + function applyPreview(hex) { + if (!hex || typeof hex !== 'string') return; + const front = hex; + const back = lighten(hex, 14); + const stroke = darken(hex, 22); + previewEl.style.setProperty('--filr-folder-front', front); + previewEl.style.setProperty('--filr-folder-back', back); + previewEl.style.setProperty('--filr-folder-stroke', stroke); + } + applyPreview(defaultHex); + inputEl?.addEventListener('input', () => applyPreview(inputEl.value)); + + // --- buttons/close + document.getElementById('closeColorFolderModal')?.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); + suppressNextToggle(300); + setTimeout(() => expandTreePath(folder, { force: true }), 0); + modal.remove(); + }); + + document.getElementById('resetFolderColorBtn')?.addEventListener('click', async (e) => { + e.preventDefault(); e.stopPropagation(); + try { + await saveFolderColor(folder, ''); // clear + showToast(t('folder_color_cleared')); + } catch (err) { + showToast(err.message || 'Error'); + } finally { + suppressNextToggle(300); + setTimeout(() => expandTreePath(folder, { force: true }), 0); + modal.remove(); + } + }); + + document.getElementById('saveFolderColorBtn')?.addEventListener('click', async (e) => { + e.preventDefault(); e.stopPropagation(); + try { + const hex = String(inputEl.value || '').trim(); + await saveFolderColor(folder, hex); + showToast(t('folder_color_saved')); + } finally { + suppressNextToggle(300); + setTimeout(() => expandTreePath(folder, { force: true }), 0); + modal.remove(); + } + }); +} + /* ---------------------- Check Current User's Folder-Only Permission ----------------------*/ @@ -287,6 +537,20 @@ async function checkUserFolderPermission() { } } +// Invalidate client-side folder "non-empty" caches +function invalidateFolderCaches(folder) { + if (!folder) return; + _folderCountCache.delete(folder); + _nonEmptyCache.delete(folder); + _inflightCounts.delete(folder); +} + +// Public: force a fresh count + icon for a folder row +export function refreshFolderIcon(folder) { + invalidateFolderCaches(folder); + ensureFolderIcon(folder); +} + // ---------------- SVG icons + icon helpers ---------------- const _nonEmptyCache = new Map(); @@ -369,7 +633,9 @@ 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)}`; + // cache-bust query param to avoid any proxy/cdn caching + const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(folder)}&t=${Date.now()}`; + const p = _runCount(url).then(data => { const result = { folders: Number(data?.folders || 0), @@ -473,15 +739,18 @@ function renderFolderTree(tree, parentPath = "", defaultDisplay = "block") { return html; } -// replace your current expandTreePath with this version function expandTreePath(path, opts = {}) { - const { force = false } = opts; + const { force = false, persist = false, includeLeaf = false } = opts; const state = loadFolderTreeState(); const parts = (path || '').split('/').filter(Boolean); let cumulative = ''; + const lastIndex = includeLeaf ? parts.length - 1 : Math.max(0, parts.length - 2); + parts.forEach((part, i) => { cumulative = i === 0 ? part : `${cumulative}/${part}`; + if (i > lastIndex) return; // skip leaf unless asked + const option = document.querySelector(`.folder-option[data-folder="${CSS.escape(cumulative)}"]`); if (!option) return; @@ -489,12 +758,17 @@ function expandTreePath(path, opts = {}) { const nestedUl = li ? li.querySelector(':scope > ul') : null; if (!nestedUl) return; - // Only expand if caller forces it OR saved state says "block" const shouldExpand = force || state[cumulative] === 'block'; nestedUl.classList.toggle('expanded', shouldExpand); nestedUl.classList.toggle('collapsed', !shouldExpand); li.setAttribute('aria-expanded', String(!!shouldExpand)); + + if (persist && shouldExpand) { + state[cumulative] = 'block'; + } }); + + if (persist) saveFolderTreeState(state); } @@ -514,58 +788,72 @@ function folderDropHandler(event) { event.preventDefault(); event.currentTarget.classList.remove("drop-hover"); const dropFolder = event.currentTarget.getAttribute("data-folder"); + let dragData = null; try { const jsonStr = event.dataTransfer.getData("application/json") || ""; if (jsonStr) dragData = JSON.parse(jsonStr); - } - catch (e) { + } catch (e) { console.error("Invalid drag data", e); return; } - /* FOLDER MOVE FALLBACK */ + + // FOLDER MOVE FALLBACK (folder->folder) if (!dragData) { const plain = (event.dataTransfer && event.dataTransfer.getData("application/x-filerise-folder")) || (event.dataTransfer && event.dataTransfer.getData("text/plain")) || ""; - if (plain) { - const sourceFolder = String(plain).trim(); - if (sourceFolder && sourceFolder !== "root") { - if (dropFolder === sourceFolder || (dropFolder + "/").startsWith(sourceFolder + "/")) { - showToast("Invalid destination.", 4000); - return; - } - fetchWithCsrf("/api/folder/moveFolder.php", { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ source: sourceFolder, destination: dropFolder }) - }) - .then(safeJson) - .then(data => { - if (data && !data.error) { - showToast(`Folder moved to ${dropFolder}!`); - if (window.currentFolder && (window.currentFolder === sourceFolder || window.currentFolder.startsWith(sourceFolder + "/"))) { - const base = sourceFolder.split("/").pop(); - const newPath = (dropFolder === "root" ? "" : dropFolder + "/") + base; - window.currentFolder = newPath; - } - return loadFolderTree().then(() => { - try { expandTreePath(window.currentFolder || "root", { persist: false, includeLeaf: false }); } catch (_) { } - loadFileList(window.currentFolder || "root"); - }); - } else { - showToast("Error: " + (data && data.error || "Could not move folder"), 5000); - } - }) - .catch(err => { - console.error("Error moving folder:", err); - showToast("Error moving folder", 5000); - }); - } + const sourceFolder = String(plain || "").trim(); + if (!sourceFolder || sourceFolder === "root") return; + + if (dropFolder === sourceFolder || (dropFolder + "/").startsWith(sourceFolder + "/")) { + showToast("Invalid destination.", 4000); + return; } + + fetchWithCsrf("/api/folder/moveFolder.php", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ source: sourceFolder, destination: dropFolder }) + }) + .then(safeJson) + .then(data => { + if (data && !data.error) { + showToast(`Folder moved to ${dropFolder}!`); + + if (window.currentFolder && + (window.currentFolder === sourceFolder || window.currentFolder.startsWith(sourceFolder + "/"))) { + const base = sourceFolder.split("/").pop(); + const newPath = (dropFolder === "root" ? "" : dropFolder + "/") + base; + + // carry color without await + const oldColor = window.folderColorMap[sourceFolder]; + if (oldColor) { + saveFolderColor(newPath, oldColor) + .then(() => saveFolderColor(sourceFolder, '')) + .catch(() => { }); + } + + window.currentFolder = newPath; + } + + return loadFolderTree().then(() => { + try { expandTreePath(window.currentFolder || "root", { persist: false, includeLeaf: false }); } catch (_) { } + loadFileList(window.currentFolder || "root"); + }); + } else { + showToast("Error: " + (data && data.error || "Could not move folder"), 5000); + } + }) + .catch(err => { + console.error("Error moving folder:", err); + showToast("Error moving folder", 5000); + }); + return; } + // File(s) drop path (unchanged) const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []); if (filesToMove.length === 0) return; @@ -584,6 +872,8 @@ function folderDropHandler(event) { if (data.success) { showToast(`File(s) moved successfully to ${dropFolder}!`); loadFileList(dragData.sourceFolder); + refreshFolderIcon(dragData.sourceFolder); + refreshFolderIcon(dropFolder); } else { showToast("Error moving files: " + (data.error || "Unknown error")); } @@ -721,6 +1011,11 @@ export async function loadFolderTree(selectedFolder) { } container.innerHTML = html; + await loadFolderColors(); + try { applyAllFolderColors(container); } catch (e) { + console.warn('applyAllFolderColors failed:', e); + } + const st = loadFolderTreeState(); const rootUl = container.querySelector('#rootRow + ul'); if (rootUl) { @@ -777,7 +1072,7 @@ export async function loadFolderTree(selectedFolder) { // Show ancestors so the current selection is visible, but don't persist if (window.currentFolder && window.currentFolder !== effectiveRoot) { - expandTreePath(window.currentFolder, { persist: false, includeLeaf: false }); + expandTreePath(window.currentFolder, { force: true, persist: true, includeLeaf: false }); } const selectedEl = container.querySelector(`.folder-option[data-folder="${window.currentFolder}"]`); @@ -811,45 +1106,48 @@ export async function loadFolderTree(selectedFolder) { }); }); - // Root toggle - const rootToggle = container.querySelector("#rootRow .folder-toggle"); - if (rootToggle) { - rootToggle.addEventListener("click", function (e) { + // --- One delegated toggle handler (robust) --- + (function bindToggleDelegation() { + const container = document.getElementById('folderTreeContainer'); + if (!container || container._toggleBound) return; + container._toggleBound = true; + + container.addEventListener('click', (e) => { + if (performance.now() < _suppressToggleUntil) { + e.stopPropagation(); + e.preventDefault(); + return; + } + const btn = e.target.closest('button.folder-toggle'); + if (!btn || !container.contains(btn)) return; e.stopPropagation(); - const nestedUl = container.querySelector("#rootRow + ul"); - if (!nestedUl) return; - const state = loadFolderTreeState(); - const expanded = !(nestedUl.classList.contains("expanded")); - nestedUl.classList.toggle("expanded", expanded); - nestedUl.classList.toggle("collapsed", !expanded); + const folderPath = btn.getAttribute('data-folder'); + let siblingUl = null; + let expandedTarget = null; - document.getElementById("rootRow").setAttribute("aria-expanded", String(expanded)); - state[effectiveRoot] = expanded ? "block" : "none"; - saveFolderTreeState(state); - }); - } - - // Other toggles - - container.querySelectorAll("button.folder-toggle").forEach(toggle => { - toggle.addEventListener("click", function (e) { - e.stopPropagation(); - const li = this.closest('li[role="treeitem"]'); - const siblingUl = li ? li.querySelector(':scope > ul') : null; - const folderPath = this.getAttribute("data-folder"); + // Root toggle? + if (btn.closest('#rootRow')) { + siblingUl = container.querySelector('#rootRow + ul'); + expandedTarget = document.getElementById('rootRow'); + } else { + const li = btn.closest('li[role="treeitem"]'); + if (!li) return; + siblingUl = li.querySelector(':scope > ul'); + expandedTarget = li; + } if (!siblingUl) return; + const expanded = !siblingUl.classList.contains('expanded'); + siblingUl.classList.toggle('expanded', expanded); + siblingUl.classList.toggle('collapsed', !expanded); + if (expandedTarget) expandedTarget.setAttribute('aria-expanded', String(expanded)); + const state = loadFolderTreeState(); - const expanded = !(siblingUl.classList.contains("expanded")); - siblingUl.classList.toggle("expanded", expanded); - siblingUl.classList.toggle("collapsed", !expanded); - li.setAttribute("aria-expanded", String(expanded)); - state[folderPath] = expanded ? "block" : "none"; + state[folderPath] = expanded ? 'block' : 'none'; saveFolderTreeState(state); - ensureFolderIcon(folderPath); - }); - }); + }, true); + })(); } catch (error) { console.error("Error loading folder tree:", error); @@ -928,6 +1226,16 @@ if (submitRename) { if (data.success) { showToast("Folder renamed successfully!"); window.currentFolder = newFolderFull; + + // carry color without await + const oldPath = selectedFolder; + const oldColor = window.folderColorMap[oldPath]; + if (oldColor) { + saveFolderColor(newFolderFull, oldColor) + .then(() => saveFolderColor(oldPath, '')) + .catch(() => { }); + } + localStorage.setItem("lastOpenedFolder", newFolderFull); loadFolderList(newFolderFull); } else { @@ -1020,6 +1328,8 @@ if (confirmDelete) { if (data.success) { showToast("Folder deleted successfully!"); window.currentFolder = getParentFolder(selectedFolder); + const parentForIcon = getParentFolder(selectedFolder); +refreshFolderIcon(parentForIcon); localStorage.setItem("lastOpenedFolder", window.currentFolder); loadFolderList(window.currentFolder); } else { @@ -1083,6 +1393,8 @@ if (submitCreate) { .then(data => { if (!data.success) throw new Error(data.error || "Server rejected the request"); showToast("Folder created!"); + const parentForIcon = parent || 'root'; +refreshFolderIcon(parentForIcon); const full = parent ? `${parent}/${folderInput}` : folderInput; window.currentFolder = full; localStorage.setItem("lastOpenedFolder", full); @@ -1101,6 +1413,45 @@ if (submitCreate) { } // ---------- CONTEXT MENU SUPPORT FOR FOLDER MANAGER ---------- +async function folderManagerContextMenuHandler(e) { + const target = e.target.closest(".folder-option, .breadcrumb-link"); + if (!target) return; + + e.preventDefault(); + e.stopPropagation(); + + const folder = target.getAttribute("data-folder"); + if (!folder) return; + + window.currentFolder = folder; + await applyFolderCapabilities(folder); // <-- await ensures fresh caps + + // Visual selection + document.querySelectorAll(".folder-option, .breadcrumb-link") + .forEach(el => el.classList.remove("selected")); + target.classList.add("selected"); + + const canColor = !!(window.currentFolderCaps && window.currentFolderCaps.canRename); + + const menuItems = [ + { + label: t("create_folder"), action: () => { + const modal = document.getElementById("createFolderModal"); + const input = document.getElementById("newFolderName"); + if (modal) modal.style.display = "block"; + if (input) input.focus(); + } + }, + { label: t("move_folder"), action: () => openMoveFolderUI(folder) }, + { label: t("rename_folder"), action: () => openRenameFolderModal() }, + ...(canColor ? [{ label: t("color_folder"), action: () => openColorFolderModal(folder) }] : []), + { label: t("folder_share"), action: () => openFolderShareModal(folder) }, + { label: t("delete_folder"), action: () => openDeleteFolderModal() } + ]; + + showFolderManagerContextMenu(e.pageX, e.pageY, menuItems); +} + export function showFolderManagerContextMenu(x, y, menuItems) { let menu = document.getElementById("folderManagerContextMenu"); if (!menu) { @@ -1112,6 +1463,7 @@ export function showFolderManagerContextMenu(x, y, menuItems) { menu.style.zIndex = "9999"; document.body.appendChild(menu); } + if (document.body.classList.contains("dark-mode")) { menu.style.backgroundColor = "#2c2c2c"; menu.style.border = "1px solid #555"; @@ -1121,18 +1473,16 @@ export function showFolderManagerContextMenu(x, y, menuItems) { menu.style.border = "1px solid #ccc"; menu.style.color = "#000"; } + menu.innerHTML = ""; menuItems.forEach(item => { const menuItem = document.createElement("div"); menuItem.textContent = item.label; menuItem.style.padding = "5px 15px"; menuItem.style.cursor = "pointer"; + menuItem.addEventListener("mouseover", () => { - if (document.body.classList.contains("dark-mode")) { - menuItem.style.backgroundColor = "#444"; - } else { - menuItem.style.backgroundColor = "#f0f0f0"; - } + menuItem.style.backgroundColor = document.body.classList.contains("dark-mode") ? "#444" : "#f0f0f0"; }); menuItem.addEventListener("mouseout", () => { menuItem.style.backgroundColor = ""; @@ -1141,75 +1491,26 @@ export function showFolderManagerContextMenu(x, y, menuItems) { item.action(); hideFolderManagerContextMenu(); }); + menu.appendChild(menuItem); }); - menu.style.left = x + "px"; - menu.style.top = y + "px"; + + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; menu.style.display = "block"; } export function hideFolderManagerContextMenu() { const menu = document.getElementById("folderManagerContextMenu"); - if (menu) { - menu.style.display = "none"; - } -} - -function folderManagerContextMenuHandler(e) { - const target = e.target.closest(".folder-option, .breadcrumb-link"); - if (!target) return; - - e.preventDefault(); - e.stopPropagation(); - - const folder = target.getAttribute("data-folder"); - if (!folder) return; - window.currentFolder = folder; - applyFolderCapabilities(window.currentFolder); - - // Visual selection - document.querySelectorAll(".folder-option, .breadcrumb-link").forEach(el => el.classList.remove("selected")); - target.classList.add("selected"); - - const menuItems = [ - { - label: t("create_folder"), - action: () => { - const modal = document.getElementById("createFolderModal"); - const input = document.getElementById("newFolderName"); - if (modal) modal.style.display = "block"; - if (input) input.focus(); - } - }, - { - label: t("move_folder"), - action: () => { openMoveFolderUI(folder); } - }, - { - label: t("rename_folder"), - action: () => { openRenameFolderModal(); } - }, - { - label: t("folder_share"), - action: () => { openFolderShareModal(folder); } - }, - { - label: t("delete_folder"), - action: () => { openDeleteFolderModal(); } - } - ]; - showFolderManagerContextMenu(e.pageX, e.pageY, menuItems); + if (menu) menu.style.display = "none"; } // Delegate contextmenu so it works with dynamically re-rendered breadcrumbs function bindFolderManagerContextMenu() { const tree = document.getElementById("folderTreeContainer"); if (tree) { - // remove old bound handler if present - if (tree._ctxHandler) { - tree.removeEventListener("contextmenu", tree._ctxHandler, false); - } - tree._ctxHandler = function (e) { + if (tree._ctxHandler) tree.removeEventListener("contextmenu", tree._ctxHandler, false); + tree._ctxHandler = (e) => { const onOption = e.target.closest(".folder-option"); if (!onOption) return; folderManagerContextMenuHandler(e); @@ -1219,10 +1520,8 @@ function bindFolderManagerContextMenu() { const title = document.getElementById("fileListTitle"); if (title) { - if (title._ctxHandler) { - title.removeEventListener("contextmenu", title._ctxHandler, false); - } - title._ctxHandler = function (e) { + if (title._ctxHandler) title.removeEventListener("contextmenu", title._ctxHandler, false); + title._ctxHandler = (e) => { const onCrumb = e.target.closest(".breadcrumb-link"); if (!onCrumb) return; folderManagerContextMenuHandler(e); @@ -1266,6 +1565,22 @@ document.addEventListener("DOMContentLoaded", function () { } }); +document.addEventListener("DOMContentLoaded", function () { + const colorFolderBtn = document.getElementById("colorFolderBtn"); + if (colorFolderBtn) { + colorFolderBtn.addEventListener("click", () => { + const selectedFolder = window.currentFolder || "root"; + if (!selectedFolder || selectedFolder === "root") { + showToast(t('please_select_valid_folder') || "Please select a valid folder."); + return; + } + openColorFolderModal(selectedFolder); + }); + } else { + console.warn("colorFolderBtn element not found in the DOM."); + } +}); + // Initial context menu delegation bind bindFolderManagerContextMenu(); @@ -1309,6 +1624,13 @@ document.addEventListener("DOMContentLoaded", () => { await loadFolderTree(); const base = source.split('/').pop(); const newPath = (destination === 'root' ? '' : destination + '/') + base; + const oldColor = window.folderColorMap[source]; + if (oldColor) { + try { + await saveFolderColor(newPath, oldColor); + await saveFolderColor(source, ''); + } catch (_) { } + } window.currentFolder = newPath; loadFileList(window.currentFolder || 'root'); } else { diff --git a/public/js/i18n.js b/public/js/i18n.js index 3484e48..cfc49a1 100644 --- a/public/js/i18n.js +++ b/public/js/i18n.js @@ -312,7 +312,13 @@ const translations = { "previous": "Previous", "next": "Next", "watched": "Watched", - "reset_progress": "Reset Progress" + "reset_progress": "Reset Progress", + "color_folder": "Color folder", + "choose_color": "Choose a color", + "reset_default": "Reset", + "save_color": "Save", + "folder_color_saved": "Folder color saved.", + "folder_color_cleared": "Folder color reset." }, es: { "please_log_in_to_continue": "Por favor, inicie sesiĂłn para continuar.", diff --git a/public/js/trashRestoreDelete.js b/public/js/trashRestoreDelete.js index 1420e89..8b3f16c 100644 --- a/public/js/trashRestoreDelete.js +++ b/public/js/trashRestoreDelete.js @@ -2,7 +2,7 @@ import { sendRequest } from './networkUtils.js?v={{APP_QVER}}'; import { toggleVisibility, showToast } from './domUtils.js?v={{APP_QVER}}'; import { loadFileList } from './fileListView.js?v={{APP_QVER}}'; -import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}'; +import { loadFolderTree, refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}'; import { t } from './i18n.js?v={{APP_QVER}}'; function showConfirm(message, onConfirm) { @@ -89,6 +89,7 @@ export function setupTrashRestoreDelete() { toggleVisibility("restoreFilesModal", false); loadFileList(window.currentFolder); loadFolderTree(window.currentFolder); + refreshFolderIcon(window.currentFolder); }) .catch(err => { console.error("Error restoring files:", err); diff --git a/public/js/upload.js b/public/js/upload.js index 064f66b..6f0edf8 100644 --- a/public/js/upload.js +++ b/public/js/upload.js @@ -3,6 +3,7 @@ import { displayFilePreview } from './filePreview.js?v={{APP_QVER}}'; import { showToast, escapeHTML } from './domUtils.js?v={{APP_QVER}}'; import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}'; import { loadFileList } from './fileListView.js?v={{APP_QVER}}'; +import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}'; import { t } from './i18n.js?v={{APP_QVER}}'; /* ----------------------------------------------------- @@ -588,7 +589,7 @@ async function initResumableUpload() { if (removeBtn) removeBtn.style.display = "none"; setTimeout(() => li.remove(), 5000); } - + refreshFolderIcon(window.currentFolder); loadFileList(window.currentFolder); }); diff --git a/resources/light-color-folder.png b/resources/light-color-folder.png new file mode 100644 index 0000000..3ebb432 Binary files /dev/null and b/resources/light-color-folder.png differ diff --git a/scripts/manual-sync.sh b/scripts/manual-sync.sh new file mode 100644 index 0000000..2493959 --- /dev/null +++ b/scripts/manual-sync.sh @@ -0,0 +1,50 @@ +# === Update FileRise to v1.9.1 (safe rsync) === +set -euo pipefail + +VER="v1.9.1" +ASSET="FileRise-${VER}.zip" # If the asset name is different, set it exactly (e.g. FileRise-v1.9.0.zip) +WEBROOT="/var/www" +TMP="/tmp/filerise-update" + +# 0) (optional) quick backup of critical bits +stamp="$(date +%F-%H%M)" +mkdir -p /root/backups +tar -C "$WEBROOT" -czf "/root/backups/filerise-$stamp.tgz" \ + public/.htaccess config users uploads metadata || true +echo "Backup saved to /root/backups/filerise-$stamp.tgz" + +# 1) Fetch the release zip +rm -rf "$TMP" && mkdir -p "$TMP" +curl -L "https://github.com/error311/FileRise/releases/download/${VER}/${ASSET}" -o "$TMP/$ASSET" + +# 2) Unzip to a staging dir +unzip -q "$TMP/$ASSET" -d "$TMP" +STAGE_DIR="$(find "$TMP" -maxdepth 1 -type d -name 'FileRise*' ! -path "$TMP" | head -n1 || true)" +[ -n "${STAGE_DIR:-}" ] || STAGE_DIR="$TMP" + +# 3) Sync code into /var/www +# - keep public/.htaccess +# - keep data dirs and current config.php +rsync -a --delete \ + --exclude 'public/.htaccess' \ + --exclude 'uploads/***' \ + --exclude 'users/***' \ + --exclude 'metadata/***' \ + --exclude 'config/config.php' \ + --exclude '.github/***' \ + --exclude 'docker-compose.yml' \ + "$STAGE_DIR"/ "$WEBROOT"/ + +# 4) Ownership (Ubuntu/Debian w/ Apache) +chown -R www-data:www-data "$WEBROOT" + +# 5) (optional) Composer autoload optimization if composer is available +if command -v composer >/dev/null 2>&1; then + cd "$WEBROOT" + composer install --no-dev --optimize-autoloader +fi + +# 6) Reload Apache +systemctl reload apache2 + +echo "âś… FileRise updated to ${VER} (code). Data and public/.htaccess preserved." \ No newline at end of file diff --git a/src/controllers/FolderController.php b/src/controllers/FolderController.php index 2a8ef36..d561301 100644 --- a/src/controllers/FolderController.php +++ b/src/controllers/FolderController.php @@ -5,6 +5,7 @@ require_once __DIR__ . '/../../config/config.php'; require_once PROJECT_ROOT . '/src/models/FolderModel.php'; require_once PROJECT_ROOT . '/src/models/UserModel.php'; require_once PROJECT_ROOT . '/src/lib/ACL.php'; +require_once PROJECT_ROOT . '/src/models/FolderMeta.php'; class FolderController { @@ -77,10 +78,59 @@ class FolderController if (isset($all[$lk])) return (array)$all[$lk]; } } - } catch (\Throwable $e) { /* ignore */ } + } catch (\Throwable $e) { /* ignore */ + } return []; } + private static function migrateFolderColors(string $source, string $target): array + { + // PHP 8 polyfill + if (!function_exists('str_starts_with')) { + function str_starts_with(string $haystack, string $needle): bool + { + return $needle === '' || strncmp($haystack, $needle, strlen($needle)) === 0; + } + } + + $metaDir = rtrim(META_DIR, '/\\'); + $file = $metaDir . '/folder_colors.json'; + + // Read current map (treat unreadable/invalid as empty) + $raw = @file_get_contents($file); + $map = is_string($raw) ? json_decode($raw, true) : []; + if (!is_array($map)) $map = []; + + // Nothing to do fast-path + $prefixSrc = $source; + $prefixNeed = $source . '/'; + $changed = false; + $new = $map; + $movedCount = 0; + + foreach ($map as $key => $hex) { + if ($key === $prefixSrc || str_starts_with($key . '/', $prefixNeed)) { + unset($new[$key]); + $suffix = substr($key, strlen($prefixSrc)); // '' or '/sub/...' + $newKey = ($target === 'root') ? ltrim($suffix, '/\\') : rtrim($target, '/\\') . $suffix; + $new[$newKey] = $hex; + $changed = true; + $movedCount++; + } + } + + if ($changed) { + // Write back (atomic-ish). Ignore failures (don’t block the move). + $json = json_encode($new, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if (is_string($json)) { + @file_put_contents($file, $json, LOCK_EX); + @chmod($file, 0664); + } + } + + return ['changed' => $changed, 'moved' => $movedCount]; + } + private static function getPerms(): array { self::ensureSession(); @@ -158,64 +208,94 @@ class FolderController * Returns null if allowed, or an error string if forbidden. */ // In FolderController.php -private static function enforceFolderScope( - string $folder, - string $username, - array $perms, - string $need = 'read' -): ?string { - // Admins bypass scope - if (self::isAdmin($perms)) return null; + private static function enforceFolderScope( + string $folder, + string $username, + array $perms, + string $need = 'read' + ): ?string { + // Admins bypass scope + if (self::isAdmin($perms)) return null; - // If this account isn't folder-scoped, don't gate here - if (!self::isFolderOnly($perms)) return null; + // If this account isn't folder-scoped, don't gate here + if (!self::isFolderOnly($perms)) return null; - $folder = ACL::normalizeFolder($folder); + $folder = ACL::normalizeFolder($folder); - // If user owns folder or an ancestor, allow - $f = $folder; - while ($f !== '' && strtolower($f) !== 'root') { - if (ACL::isOwner($username, $perms, $f)) return null; - $pos = strrpos($f, '/'); - $f = ($pos === false) ? '' : substr($f, 0, $pos); + // If user owns folder or an ancestor, allow + $f = $folder; + while ($f !== '' && strtolower($f) !== 'root') { + if (ACL::isOwner($username, $perms, $f)) return null; + $pos = strrpos($f, '/'); + $f = ($pos === false) ? '' : substr($f, 0, $pos); + } + + // Normalize aliases so callers can pass either camelCase or snake_case + switch ($need) { + case 'manage': + $ok = ACL::canManage($username, $perms, $folder); + break; + + // legacy: + case 'write': + $ok = ACL::canWrite($username, $perms, $folder); + break; + case 'share': + $ok = ACL::canShare($username, $perms, $folder); + break; + + // read flavors: + case 'read_own': + $ok = ACL::canReadOwn($username, $perms, $folder); + break; + case 'read': + $ok = ACL::canRead($username, $perms, $folder); + break; + + // granular write-ish: + case 'create': + $ok = ACL::canCreate($username, $perms, $folder); + break; + case 'upload': + $ok = ACL::canUpload($username, $perms, $folder); + break; + case 'edit': + $ok = ACL::canEdit($username, $perms, $folder); + break; + case 'rename': + $ok = ACL::canRename($username, $perms, $folder); + break; + case 'copy': + $ok = ACL::canCopy($username, $perms, $folder); + break; + case 'move': + $ok = ACL::canMove($username, $perms, $folder); + break; + case 'delete': + $ok = ACL::canDelete($username, $perms, $folder); + break; + case 'extract': + $ok = ACL::canExtract($username, $perms, $folder); + break; + + // granular share (support both key styles) + case 'shareFile': + case 'share_file': + $ok = ACL::canShareFile($username, $perms, $folder); + break; + case 'shareFolder': + case 'share_folder': + $ok = ACL::canShareFolder($username, $perms, $folder); + break; + + default: + // Default to full read if unknown need was passed + $ok = ACL::canRead($username, $perms, $folder); + } + + return $ok ? null : "Forbidden: folder scope violation."; } - // Normalize aliases so callers can pass either camelCase or snake_case - switch ($need) { - case 'manage': $ok = ACL::canManage($username, $perms, $folder); break; - - // legacy: - case 'write': $ok = ACL::canWrite($username, $perms, $folder); break; - case 'share': $ok = ACL::canShare($username, $perms, $folder); break; - - // read flavors: - case 'read_own': $ok = ACL::canReadOwn($username, $perms, $folder); break; - case 'read': $ok = ACL::canRead($username, $perms, $folder); break; - - // granular write-ish: - case 'create': $ok = ACL::canCreate($username, $perms, $folder); break; - case 'upload': $ok = ACL::canUpload($username, $perms, $folder); break; - case 'edit': $ok = ACL::canEdit($username, $perms, $folder); break; - case 'rename': $ok = ACL::canRename($username, $perms, $folder); break; - case 'copy': $ok = ACL::canCopy($username, $perms, $folder); break; - case 'move': $ok = ACL::canMove($username, $perms, $folder); break; - case 'delete': $ok = ACL::canDelete($username, $perms, $folder); break; - case 'extract': $ok = ACL::canExtract($username, $perms, $folder); break; - - // granular share (support both key styles) - case 'shareFile': - case 'share_file': $ok = ACL::canShareFile($username, $perms, $folder); break; - case 'shareFolder': - case 'share_folder':$ok = ACL::canShareFolder($username, $perms, $folder); break; - - default: - // Default to full read if unknown need was passed - $ok = ACL::canRead($username, $perms, $folder); - } - - return $ok ? null : "Forbidden: folder scope violation."; -} - /** Returns true if caller can ignore ownership (admin or bypassOwnership/default). */ private static function canBypassOwnership(array $perms): bool { @@ -231,92 +311,128 @@ private static function enforceFolderScope( /* -------------------- API: Create Folder -------------------- */ public function createFolder(): void -{ - header('Content-Type: application/json'); - self::requireAuth(); - if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed.']); return; } - self::requireCsrf(); - self::requireNotReadOnly(); - - try { - $input = json_decode(file_get_contents('php://input'), true) ?? []; - if (!isset($input['folderName'])) { http_response_code(400); echo json_encode(['error' => 'Folder name not provided.']); return; } - - $folderName = trim((string)$input['folderName']); - $parentIn = isset($input['parent']) ? trim((string)$input['parent']) : 'root'; - - if (!preg_match(REGEX_FOLDER_NAME, $folderName)) { - http_response_code(400); echo json_encode(['error' => 'Invalid folder name.']); return; - } - if ($parentIn !== '' && strcasecmp($parentIn, 'root') !== 0 && !preg_match(REGEX_FOLDER_NAME, $parentIn)) { - http_response_code(400); echo json_encode(['error' => 'Invalid parent folder name.']); return; - } - - $parent = ($parentIn === '' ? 'root' : $parentIn); - - $username = $_SESSION['username'] ?? ''; - $perms = self::getPerms(); - - // Need create on parent OR ownership on parent/ancestor - if (!(ACL::canCreateFolder($username, $perms, $parent) || self::ownsFolderOrAncestor($parent, $username, $perms))) { - http_response_code(403); - echo json_encode(['error' => 'Forbidden: manager/owner required on parent.']); - exit; - } - - // Folder-scope gate for folder-only accounts (need create on parent) - if ($msg = self::enforceFolderScope($parent, $username, $perms, 'manage')) { - http_response_code(403); echo json_encode(['error' => $msg]); return; - } - - $result = FolderModel::createFolder($folderName, $parent, $username); - if (empty($result['success'])) { - http_response_code(400); - echo json_encode($result); + { + header('Content-Type: application/json'); + self::requireAuth(); + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + echo json_encode(['error' => 'Method not allowed.']); return; } + self::requireCsrf(); + self::requireNotReadOnly(); - echo json_encode($result); - } catch (Throwable $e) { - error_log('createFolder fatal: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine()); - http_response_code(500); - echo json_encode(['error' => 'Internal error creating folder.']); + try { + $input = json_decode(file_get_contents('php://input'), true) ?? []; + if (!isset($input['folderName'])) { + http_response_code(400); + echo json_encode(['error' => 'Folder name not provided.']); + return; + } + + $folderName = trim((string)$input['folderName']); + $parentIn = isset($input['parent']) ? trim((string)$input['parent']) : 'root'; + + if (!preg_match(REGEX_FOLDER_NAME, $folderName)) { + http_response_code(400); + echo json_encode(['error' => 'Invalid folder name.']); + return; + } + if ($parentIn !== '' && strcasecmp($parentIn, 'root') !== 0 && !preg_match(REGEX_FOLDER_NAME, $parentIn)) { + http_response_code(400); + echo json_encode(['error' => 'Invalid parent folder name.']); + return; + } + + $parent = ($parentIn === '' ? 'root' : $parentIn); + + $username = $_SESSION['username'] ?? ''; + $perms = self::getPerms(); + + // Need create on parent OR ownership on parent/ancestor + if (!(ACL::canCreateFolder($username, $perms, $parent) || self::ownsFolderOrAncestor($parent, $username, $perms))) { + http_response_code(403); + echo json_encode(['error' => 'Forbidden: manager/owner required on parent.']); + exit; + } + + // Folder-scope gate for folder-only accounts (need create on parent) + if ($msg = self::enforceFolderScope($parent, $username, $perms, 'manage')) { + http_response_code(403); + echo json_encode(['error' => $msg]); + return; + } + + $result = FolderModel::createFolder($folderName, $parent, $username); + if (empty($result['success'])) { + http_response_code(400); + echo json_encode($result); + return; + } + + echo json_encode($result); + } catch (Throwable $e) { + error_log('createFolder fatal: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine()); + http_response_code(500); + echo json_encode(['error' => 'Internal error creating folder.']); + } } -} /* -------------------- API: Delete Folder -------------------- */ public function deleteFolder(): void { header('Content-Type: application/json'); self::requireAuth(); - if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(["error" => "Method not allowed."]); exit; } + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + echo json_encode(["error" => "Method not allowed."]); + exit; + } self::requireCsrf(); self::requireNotReadOnly(); $input = json_decode(file_get_contents('php://input'), true); - if (!isset($input['folder'])) { http_response_code(400); echo json_encode(["error" => "Folder name not provided."]); exit; } + if (!isset($input['folder'])) { + http_response_code(400); + echo json_encode(["error" => "Folder name not provided."]); + exit; + } $folder = trim((string)$input['folder']); - if (strcasecmp($folder, 'root') === 0) { http_response_code(400); echo json_encode(["error" => "Cannot delete root folder."]); exit; } - if (!preg_match(REGEX_FOLDER_NAME, $folder)) { http_response_code(400); echo json_encode(["error" => "Invalid folder name."]); exit; } + if (strcasecmp($folder, 'root') === 0) { + http_response_code(400); + echo json_encode(["error" => "Cannot delete root folder."]); + exit; + } + if (!preg_match(REGEX_FOLDER_NAME, $folder)) { + http_response_code(400); + echo json_encode(["error" => "Invalid folder name."]); + exit; + } $username = $_SESSION['username'] ?? ''; $perms = self::getPerms(); // Folder-scope: need manage (owner) OR explicit manage grant if ($msg = self::enforceFolderScope($folder, $username, $perms, 'manage')) { - http_response_code(403); echo json_encode(["error" => $msg]); exit; + http_response_code(403); + echo json_encode(["error" => $msg]); + exit; } // Require either manage permission or ancestor ownership (strong gate) $canManage = ACL::canManage($username, $perms, $folder) || self::ownsFolderOrAncestor($folder, $username, $perms); if (!$canManage) { - http_response_code(403); echo json_encode(["error" => "Forbidden: you lack manage rights for this folder."]); exit; + http_response_code(403); + echo json_encode(["error" => "Forbidden: you lack manage rights for this folder."]); + exit; } // If not bypassing ownership, require ownership (direct or ancestor) as an extra safeguard if (!self::canBypassOwnership($perms) && !self::ownsFolderOrAncestor($folder, $username, $perms)) { - http_response_code(403); echo json_encode(["error" => "Forbidden: you are not the folder owner."]); exit; + http_response_code(403); + echo json_encode(["error" => "Forbidden: you are not the folder owner."]); + exit; } $result = FolderModel::deleteFolder($folder); @@ -329,20 +445,28 @@ private static function enforceFolderScope( { header('Content-Type: application/json'); self::requireAuth(); - if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed.']); exit; } + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + echo json_encode(['error' => 'Method not allowed.']); + exit; + } self::requireCsrf(); self::requireNotReadOnly(); $input = json_decode(file_get_contents('php://input'), true); if (!isset($input['oldFolder']) || !isset($input['newFolder'])) { - http_response_code(400); echo json_encode(['error' => 'Required folder names not provided.']); exit; + http_response_code(400); + echo json_encode(['error' => 'Required folder names not provided.']); + exit; } $oldFolder = trim((string)$input['oldFolder']); $newFolder = trim((string)$input['newFolder']); if (!preg_match(REGEX_FOLDER_NAME, $oldFolder) || !preg_match(REGEX_FOLDER_NAME, $newFolder)) { - http_response_code(400); echo json_encode(['error' => 'Invalid folder name(s).']); exit; + http_response_code(400); + echo json_encode(['error' => 'Invalid folder name(s).']); + exit; } $username = $_SESSION['username'] ?? ''; @@ -350,22 +474,30 @@ private static function enforceFolderScope( // Must be allowed to manage the old folder if ($msg = self::enforceFolderScope($oldFolder, $username, $perms, 'manage')) { - http_response_code(403); echo json_encode(["error" => $msg]); exit; + http_response_code(403); + echo json_encode(["error" => $msg]); + exit; } // For the new folder path, require write scope (we're "creating" a path) if ($msg = self::enforceFolderScope($newFolder, $username, $perms, 'manage')) { - http_response_code(403); echo json_encode(["error" => "New path not allowed: " . $msg]); exit; + http_response_code(403); + echo json_encode(["error" => "New path not allowed: " . $msg]); + exit; } // Strong gates: need manage on old OR ancestor owner; need manage on new parent OR ancestor owner $canManageOld = ACL::canManage($username, $perms, $oldFolder) || self::ownsFolderOrAncestor($oldFolder, $username, $perms); if (!$canManageOld) { - http_response_code(403); echo json_encode(['error' => 'Forbidden: you lack manage rights on the source folder.']); exit; + http_response_code(403); + echo json_encode(['error' => 'Forbidden: you lack manage rights on the source folder.']); + exit; } // If not bypassing ownership, require ownership (direct or ancestor) on the old folder if (!self::canBypassOwnership($perms) && !self::ownsFolderOrAncestor($oldFolder, $username, $perms)) { - http_response_code(403); echo json_encode(['error' => 'Forbidden: you are not the folder owner.']); exit; + http_response_code(403); + echo json_encode(['error' => 'Forbidden: you are not the folder owner.']); + exit; } $result = FolderModel::renameFolder($oldFolder, $newFolder); @@ -404,7 +536,10 @@ private static function enforceFolderScope( // 1) Full list from model $all = FolderModel::getFolderList(); // each row: ["folder","fileCount","metadataFile"] - if (!is_array($all)) { echo json_encode([]); exit; } + if (!is_array($all)) { + echo json_encode([]); + exit; + } // 2) Filter by view rights if (!$isAdmin) { @@ -441,21 +576,79 @@ private static function enforceFolderScope( $page = filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT); if ($page === false || $page < 1) $page = 1; - if (empty($token)) { http_response_code(400); header('Content-Type: application/json'); echo json_encode(["error" => "Missing token."]); exit; } + if (empty($token)) { + http_response_code(400); + header('Content-Type: application/json'); + echo json_encode(["error" => "Missing token."]); + exit; + } $data = FolderModel::getSharedFolderData($token, $providedPass, $page); if (isset($data['needs_password']) && $data['needs_password'] === true) { header("Content-Type: text/html; charset=utf-8"); ?> - -Enter Password - -Folder ProtectedThis folder is protected by a password. Please enter the password to view its contents. -Password:Submit - + + + + + + Enter Password + + + + + + Folder Protected + This folder is protected by a password. Please enter the password to view its contents. + Password:Submit + + + + + $data['error']]); exit; } + if (isset($data['error'])) { + http_response_code(403); + header('Content-Type: application/json'); + echo json_encode(["error" => $data['error']]); + exit; + } require_once PROJECT_ROOT . '/src/models/AdminModel.php'; $adminConfig = AdminModel::getConfig(); @@ -468,72 +661,222 @@ private static function enforceFolderScope( $totalPages = $data['totalPages']; header("Content-Type: text/html; charset=utf-8"); ?> - - - -Shared Folder: - - - -Shared Folder: - -Switch to Gallery View - - -This folder is empty. - -FilenameSize - - ⇩ - - - - - - - 1): ?> -">Prev -Prev - - -"> - - -">Next -Next - + + - - -Upload File ( max size) - - -Upload - - - - - - - - - + + + + Shared Folder: + + + + + + Shared Folder: + + + Switch to Gallery View + + + This folder is empty. + + + + + Filename + Size + + + + + + ⇩ + + + + + + + + + + 1): ?> + ">Prev + Prev + + + "> + + + ">Next + Next + + + + + Upload File ( max size) + + + Upload + + + + + + + + + + self::requireNotReadOnly(); $in = json_decode(file_get_contents("php://input"), true); - if (!$in || !isset($in['folder'])) { http_response_code(400); echo json_encode(["error" => "Invalid input."]); exit; } + if (!$in || !isset($in['folder'])) { + http_response_code(400); + echo json_encode(["error" => "Invalid input."]); + exit; + } $folder = trim((string)$in['folder']); $value = isset($in['expirationValue']) ? intval($in['expirationValue']) : 60; @@ -555,7 +902,11 @@ for ($i = $startPage; $i <= $endPage; $i++): ?> $password = (string)($in['password'] ?? ''); $allowUpload = intval($in['allowUpload'] ?? 0); - if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { http_response_code(400); echo json_encode(["error" => "Invalid folder name."]); exit; } + if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { + http_response_code(400); + echo json_encode(["error" => "Invalid folder name."]); + exit; + } $username = $_SESSION['username'] ?? ''; $perms = self::getPerms(); @@ -563,30 +914,46 @@ for ($i = $startPage; $i <= $endPage; $i++): ?> // Must have share on this folder OR be ancestor owner if (!(ACL::canShare($username, $perms, $folder) || self::ownsFolderOrAncestor($folder, $username, $perms))) { - http_response_code(403); echo json_encode(["error" => "Sharing is not permitted for your account."]); exit; + http_response_code(403); + echo json_encode(["error" => "Sharing is not permitted for your account."]); + exit; } // Folder-scope: need share capability within scope if ($msg = self::enforceFolderScope($folder, $username, $perms, 'share')) { - http_response_code(403); echo json_encode(["error" => $msg]); exit; + http_response_code(403); + echo json_encode(["error" => $msg]); + exit; } // Ownership requirement unless bypassed (allow ancestor owners) if (!self::canBypassOwnership($perms) && !self::ownsFolderOrAncestor($folder, $username, $perms)) { - http_response_code(403); echo json_encode(["error" => "Forbidden: you are not the owner of this folder."]); exit; + http_response_code(403); + echo json_encode(["error" => "Forbidden: you are not the owner of this folder."]); + exit; } if ($allowUpload === 1 && !empty($perms['disableUpload']) && !$isAdmin) { - http_response_code(403); echo json_encode(["error" => "You cannot enable uploads on shared folders."]); exit; + http_response_code(403); + echo json_encode(["error" => "You cannot enable uploads on shared folders."]); + exit; } if ($value < 1) $value = 1; switch ($unit) { - case 'seconds': $seconds = $value; break; - case 'hours': $seconds = $value * 3600; break; - case 'days': $seconds = $value * 86400; break; + case 'seconds': + $seconds = $value; + break; + case 'hours': + $seconds = $value * 3600; + break; + case 'days': + $seconds = $value * 86400; + break; case 'minutes': - default: $seconds = $value * 60; break; + default: + $seconds = $value * 60; + break; } $seconds = min($seconds, 31536000); @@ -601,13 +968,28 @@ for ($i = $startPage; $i <= $endPage; $i++): ?> $token = filter_input(INPUT_GET, 'token', FILTER_SANITIZE_STRING); $file = filter_input(INPUT_GET, 'file', FILTER_SANITIZE_STRING); - if (empty($token) || empty($file)) { http_response_code(400); header('Content-Type: application/json'); echo json_encode(["error" => "Missing token or file parameter."]); exit; } + if (empty($token) || empty($file)) { + http_response_code(400); + header('Content-Type: application/json'); + echo json_encode(["error" => "Missing token or file parameter."]); + exit; + } $basename = basename($file); - if (!preg_match(REGEX_FILE_NAME, $basename)) { http_response_code(400); header('Content-Type: application/json'); echo json_encode(["error" => "Invalid file name."]); exit; } + if (!preg_match(REGEX_FILE_NAME, $basename)) { + http_response_code(400); + header('Content-Type: application/json'); + echo json_encode(["error" => "Invalid file name."]); + exit; + } $result = FolderModel::getSharedFileInfo($token, $basename); - if (isset($result['error'])) { http_response_code(404); header('Content-Type: application/json'); echo json_encode(["error" => $result['error']]); exit; } + if (isset($result['error'])) { + http_response_code(404); + header('Content-Type: application/json'); + echo json_encode(["error" => $result['error']]); + exit; + } $realFilePath = $result['realFilePath']; $mimeType = $result['mimeType']; @@ -615,7 +997,7 @@ for ($i = $startPage; $i <= $endPage; $i++): ?> header('X-Content-Type-Options: nosniff'); header("Content-Type: " . $mimeType); $ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION)); - if (in_array($ext, ['jpg','jpeg','png','gif','bmp','webp','svg','ico'])) { + if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico'])) { header('Content-Disposition: inline; filename="' . basename($realFilePath) . '"'); } else { header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"'); @@ -629,12 +1011,27 @@ for ($i = $startPage; $i <= $endPage; $i++): ?> /* -------------------- API: Upload to Shared Folder -------------------- */ public function uploadToSharedFolder(): void { - if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); header('Content-Type: application/json'); echo json_encode(["error" => "Method not allowed."]); exit; } + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + header('Content-Type: application/json'); + echo json_encode(["error" => "Method not allowed."]); + exit; + } - if (empty($_POST['token'])) { http_response_code(400); header('Content-Type: application/json'); echo json_encode(["error" => "Missing share token."]); exit; } + if (empty($_POST['token'])) { + http_response_code(400); + header('Content-Type: application/json'); + echo json_encode(["error" => "Missing share token."]); + exit; + } $token = trim($_POST['token']); - if (!isset($_FILES['fileToUpload'])) { http_response_code(400); header('Content-Type: application/json'); echo json_encode(["error" => "No file was uploaded."]); exit; } + if (!isset($_FILES['fileToUpload'])) { + http_response_code(400); + header('Content-Type: application/json'); + echo json_encode(["error" => "No file was uploaded."]); + exit; + } $fileUpload = $_FILES['fileToUpload']; if (!empty($fileUpload['error']) && $fileUpload['error'] !== UPLOAD_ERR_OK) { @@ -648,11 +1045,19 @@ for ($i = $startPage; $i <= $endPage; $i++): ?> UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the file upload.' ]; $msg = $map[$fileUpload['error']] ?? 'Upload error.'; - http_response_code(400); header('Content-Type: application/json'); echo json_encode(['error' => $msg]); exit; + http_response_code(400); + header('Content-Type: application/json'); + echo json_encode(['error' => $msg]); + exit; } $result = FolderModel::uploadToSharedFolder($token, $fileUpload); - if (isset($result['error'])) { http_response_code(400); header('Content-Type: application/json'); echo json_encode($result); exit; } + if (isset($result['error'])) { + http_response_code(400); + header('Content-Type: application/json'); + echo json_encode($result); + exit; + } $_SESSION['upload_message'] = "File uploaded successfully."; $redirectUrl = "/api/folder/shareFolder.php?token=" . urlencode($token); @@ -692,7 +1097,11 @@ for ($i = $startPage; $i <= $endPage; $i++): ?> self::requireCsrf(); $token = $_POST['token'] ?? ''; - if (!$token) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'No token provided']); return; } + if (!$token) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'No token provided']); + return; + } $deleted = FolderModel::deleteShareFolderLink($token); if ($deleted) { @@ -703,78 +1112,207 @@ for ($i = $startPage; $i <= $endPage; $i++): ?> } } + public function getFolderColors(): void + { + header('Content-Type: application/json; charset=utf-8'); + self::requireAuth(); + + $user = $_SESSION['username'] ?? ''; + $perms = $this->loadPerms($user); + + $map = FolderMeta::getMap(); + $out = []; + foreach ($map as $folder => $hex) { + $folder = FolderMeta::normalizeFolder($folder); + if (ACL::canRead($user, $perms, $folder)) $out[$folder] = $hex; + } + echo json_encode($out, JSON_UNESCAPED_SLASHES); + } + + public function saveFolderColor(): void + { + header('Content-Type: application/json; charset=utf-8'); + self::requireAuth(); + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + http_response_code(405); + echo json_encode(['error' => 'Method not allowed']); + return; + } + + // CSRF + $hdr = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; + $tok = $_SESSION['csrf_token'] ?? ''; + if (!$hdr || !$tok || !hash_equals((string)$tok, (string)$hdr)) { + http_response_code(403); + echo json_encode(['error' => 'Invalid CSRF token']); + return; + } + + $user = $_SESSION['username'] ?? ''; + $perms = $this->loadPerms($user); + + $body = json_decode(file_get_contents('php://input') ?: "{}", true) ?: []; + $folder = FolderMeta::normalizeFolder((string)($body['folder'] ?? 'root')); + $color = isset($body['color']) ? (string)$body['color'] : ''; + + // Treat “customize color” as rename-level capability (your convention) + if (!ACL::canRename($user, $perms, $folder)) { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + + try { + $res = FolderMeta::setColor($folder, $color === '' ? null : $color); + echo json_encode(['success' => true] + $res, JSON_UNESCAPED_SLASHES); + } catch (\InvalidArgumentException $e) { + http_response_code(400); + echo json_encode(['error' => $e->getMessage()]); + } + } + /* -------------------- API: Move Folder -------------------- */ public function moveFolder(): void { header('Content-Type: application/json; charset=utf-8'); self::requireAuth(); - if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { http_response_code(405); echo json_encode(['error'=>'Method not allowed']); return; } + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + http_response_code(405); + echo json_encode(['error' => 'Method not allowed']); + return; + } // CSRF: accept header or form field $hdr = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; $tok = $_SESSION['csrf_token'] ?? ''; - if (!$hdr || !$tok || !hash_equals((string)$tok, (string)$hdr)) { http_response_code(403); echo json_encode(['error'=>'Invalid CSRF token']); return; } + if (!$hdr || !$tok || !hash_equals((string)$tok, (string)$hdr)) { + http_response_code(403); + echo json_encode(['error' => 'Invalid CSRF token']); + return; + } $raw = file_get_contents('php://input'); $input = json_decode($raw ?: "{}", true); $source = trim((string)($input['source'] ?? '')); $destination = trim((string)($input['destination'] ?? '')); - if ($source === '' || strcasecmp($source,'root')===0) { http_response_code(400); echo json_encode(['error'=>'Invalid source folder']); return; } + if ($source === '' || strcasecmp($source, 'root') === 0) { + http_response_code(400); + echo json_encode(['error' => 'Invalid source folder']); + return; + } if ($destination === '') $destination = 'root'; // basic segment validation - foreach ([$source,$destination] as $f) { - if ($f==='root') continue; - $parts = array_filter(explode('/', trim($f, "/\\ ")), fn($p)=>$p!==''); + foreach ([$source, $destination] as $f) { + if ($f === 'root') continue; + $parts = array_filter(explode('/', trim($f, "/\\ ")), fn($p) => $p !== ''); foreach ($parts as $seg) { - if (!preg_match(REGEX_FOLDER_NAME, $seg)) { http_response_code(400); echo json_encode(['error'=>'Invalid folder segment']); return; } + if (!preg_match(REGEX_FOLDER_NAME, $seg)) { + http_response_code(400); + echo json_encode(['error' => 'Invalid folder segment']); + return; + } } } $srcNorm = trim($source, "/\\ "); - $dstNorm = $destination==='root' ? '' : trim($destination, "/\\ "); + $dstNorm = $destination === 'root' ? '' : trim($destination, "/\\ "); // prevent move into self/descendant - if ($dstNorm !== '' && (strcasecmp($dstNorm,$srcNorm)===0 || strpos($dstNorm.'/', $srcNorm.'/')===0)) { - http_response_code(400); echo json_encode(['error'=>'Destination cannot be the source or its descendant']); return; + if ($dstNorm !== '' && (strcasecmp($dstNorm, $srcNorm) === 0 || strpos($dstNorm . '/', $srcNorm . '/') === 0)) { + http_response_code(400); + echo json_encode(['error' => 'Destination cannot be the source or its descendant']); + return; } $username = $_SESSION['username'] ?? ''; $perms = $this->loadPerms($username); // enforce scopes (source manage-ish, dest write-ish) - if ($msg = self::enforceFolderScope($source, $username, $perms, 'manage')) { http_response_code(403); echo json_encode(['error'=>$msg]); return; } - if ($msg = self::enforceFolderScope($destination, $username, $perms, 'write')) { http_response_code(403); echo json_encode(['error'=>$msg]); return; } + if ($msg = self::enforceFolderScope($source, $username, $perms, 'manage')) { + http_response_code(403); + echo json_encode(['error' => $msg]); + return; + } + if ($msg = self::enforceFolderScope($destination, $username, $perms, 'write')) { + http_response_code(403); + echo json_encode(['error' => $msg]); + return; + } // Check capabilities using ACL helpers $canManageSource = ACL::canManage($username, $perms, $source) || ACL::isOwner($username, $perms, $source); - $canMoveIntoDest = ACL::canMove($username, $perms, $destination) || ($destination==='root' ? self::isAdmin($perms) : ACL::isOwner($username, $perms, $destination)); - if (!$canManageSource) { http_response_code(403); echo json_encode(['error'=>'Forbidden: manage rights required on source']); return; } - if (!$canMoveIntoDest) { http_response_code(403); echo json_encode(['error'=>'Forbidden: move rights required on destination']); return; } + $canMoveIntoDest = ACL::canMove($username, $perms, $destination) || ($destination === 'root' ? self::isAdmin($perms) : ACL::isOwner($username, $perms, $destination)); + if (!$canManageSource) { + http_response_code(403); + echo json_encode(['error' => 'Forbidden: manage rights required on source']); + return; + } + if (!$canMoveIntoDest) { + http_response_code(403); + echo json_encode(['error' => 'Forbidden: move rights required on destination']); + return; + } // Non-admin: enforce same owner between source and destination tree (if any) $isAdmin = self::isAdmin($perms); if (!$isAdmin) { try { $ownerSrc = FolderModel::getOwnerFor($source) ?? ''; - $ownerDst = $destination==='root' ? '' : (FolderModel::getOwnerFor($destination) ?? ''); + $ownerDst = $destination === 'root' ? '' : (FolderModel::getOwnerFor($destination) ?? ''); if ($ownerSrc !== $ownerDst) { - http_response_code(403); echo json_encode(['error'=>'Source and destination must have the same owner']); return; + http_response_code(403); + echo json_encode(['error' => 'Source and destination must have the same owner']); + return; } - } catch (\Throwable $e) { /* ignore – fall through */ } + } catch (\Throwable $e) { /* ignore – fall through */ + } } // Compute final target "destination/basename(source)" - $baseName = basename(str_replace('\\','/', $srcNorm)); - $target = $destination==='root' ? $baseName : rtrim($destination, "/\\ ") . '/' . $baseName; + $baseName = basename(str_replace('\\', '/', $srcNorm)); + $target = $destination === 'root' ? $baseName : rtrim($destination, "/\\ ") . '/' . $baseName; try { $result = FolderModel::renameFolder($source, $target); - echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + $result = FolderModel::renameFolder($source, $target); + + // migrate ACL subtree (best-effort; never block the move) + $aclStats = []; + try { + $aclStats = ACL::migrateSubtree($source, $target); + } catch (\Throwable $e) { + error_log('moveFolder ACL-migration warning: ' . $e->getMessage()); + } + + // If you already added color migration, just append this too: + $resultArr = is_array($result) ? $result : ['success' => true, 'target' => $target]; + $resultArr['aclMigration'] = $aclStats + ['changed' => false, 'moved' => 0]; + + echo json_encode($resultArr, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + // If the move succeeded, migrate folder color mappings server-side + $colorStats = []; + if (is_array($result) && (!isset($result['success']) || $result['success'])) { + try { + $colorStats = self::migrateFolderColors($source, $target); + } catch (\Throwable $e) { + error_log('moveFolder color-migration warning: ' . $e->getMessage()); + } + } + + // merge stats into response (non-breaking) + if (is_array($result)) { + $result['colorMigration'] = $colorStats + ['changed' => false, 'moved' => 0]; + echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } else { + echo json_encode(['success' => true, 'target' => $target, 'colorMigration' => $colorStats + ['changed' => false, 'moved' => 0]], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } } catch (\Throwable $e) { - error_log('moveFolder error: '.$e->getMessage()); + error_log('moveFolder error: ' . $e->getMessage()); http_response_code(500); - echo json_encode(['error'=>'Internal error moving folder']); + echo json_encode(['error' => 'Internal error moving folder']); } } } diff --git a/src/lib/ACL.php b/src/lib/ACL.php index f9032da..b043c58 100644 --- a/src/lib/ACL.php +++ b/src/lib/ACL.php @@ -10,23 +10,38 @@ class ACL private static $path = null; private const BUCKETS = [ - 'owners','read','write','share','read_own', - 'create','upload','edit','rename','copy','move','delete','extract', - 'share_file','share_folder' + 'owners', + 'read', + 'write', + 'share', + 'read_own', + 'create', + 'upload', + 'edit', + 'rename', + 'copy', + 'move', + 'delete', + 'extract', + 'share_file', + 'share_folder' ]; - private static function path(): string { + private static function path(): string + { if (!self::$path) self::$path = rtrim(META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_acl.json'; return self::$path; } - public static function normalizeFolder(string $f): string { + public static function normalizeFolder(string $f): string + { $f = trim(str_replace('\\', '/', $f), "/ \t\r\n"); if ($f === '' || $f === 'root') return 'root'; return $f; } - public static function purgeUser(string $user): bool { + public static function purgeUser(string $user): bool + { $user = (string)$user; $acl = self::$cache ?? self::loadFresh(); $changed = false; @@ -41,49 +56,107 @@ class ACL return $changed ? self::save($acl) : true; } public static function ownsFolderOrAncestor(string $user, array $perms, string $folder): bool -{ - $folder = self::normalizeFolder($folder); - if (self::isAdmin($perms)) return true; - if (self::hasGrant($user, $folder, 'owners')) return true; + { + $folder = self::normalizeFolder($folder); + if (self::isAdmin($perms)) return true; + if (self::hasGrant($user, $folder, 'owners')) return true; - $folder = trim($folder, "/\\ "); - if ($folder === '' || $folder === 'root') return false; + $folder = trim($folder, "/\\ "); + if ($folder === '' || $folder === 'root') return false; - $parts = explode('/', $folder); - while (count($parts) > 1) { - array_pop($parts); - $parent = implode('/', $parts); - if (self::hasGrant($user, $parent, 'owners')) return true; + $parts = explode('/', $folder); + while (count($parts) > 1) { + array_pop($parts); + $parent = implode('/', $parts); + if (self::hasGrant($user, $parent, 'owners')) return true; + } + return false; + } + + public static function migrateSubtree(string $source, string $target): array + { + // PHP <8 polyfill + if (!function_exists('str_starts_with')) { + function str_starts_with(string $h, string $n): bool + { + return $n === '' || strncmp($h, $n, strlen($n)) === 0; + } + } + + $src = self::normalizeFolder($source); + $dst = self::normalizeFolder($target); + if ($src === 'root') return ['changed' => false, 'moved' => 0]; + + $file = self::path(); // e.g. META_DIR/folder_acl.json + $raw = @file_get_contents($file); + $map = is_string($raw) ? json_decode($raw, true) : []; + if (!is_array($map)) $map = []; + + $prefix = $src; + $needle = $src . '/'; + + $new = $map; + $changed = false; + $moved = 0; + + foreach ($map as $key => $entry) { + $isMatch = ($key === $prefix) || str_starts_with($key . '/', $needle); + if (!$isMatch) continue; + + unset($new[$key]); + + $suffix = substr($key, strlen($prefix)); // '' or '/sub/...' + $newKey = ($dst === 'root') ? ltrim($suffix, '/\\') : rtrim($dst, '/\\') . $suffix; + + // keep only known buckets (defensive) + if (is_array($entry)) { + $clean = []; + foreach (self::BUCKETS as $b) if (array_key_exists($b, $entry)) $clean[$b] = $entry[$b]; + $entry = $clean ?: $entry; + } + + // overwrite any existing entry at destination path (safer than union) + $new[$newKey] = $entry; + $changed = true; + $moved++; + } + + if ($changed) { + @file_put_contents($file, json_encode($new, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX); + @chmod($file, 0664); + self::$cache = $new; // keep in-process cache fresh if you use it + } + + return ['changed' => $changed, 'moved' => $moved]; } - return false; -} /** Re-key explicit ACL entries for an entire subtree: old/... → new/... */ -public static function renameTree(string $oldFolder, string $newFolder): void -{ - $old = self::normalizeFolder($oldFolder); - $new = self::normalizeFolder($newFolder); - if ($old === '' || $old === 'root') return; // nothing to re-key for root + public static function renameTree(string $oldFolder, string $newFolder): void + { + $old = self::normalizeFolder($oldFolder); + $new = self::normalizeFolder($newFolder); + if ($old === '' || $old === 'root') return; // nothing to re-key for root - $acl = self::$cache ?? self::loadFresh(); - if (!isset($acl['folders']) || !is_array($acl['folders'])) return; + $acl = self::$cache ?? self::loadFresh(); + if (!isset($acl['folders']) || !is_array($acl['folders'])) return; - $rebased = []; - foreach ($acl['folders'] as $k => $rec) { - if ($k === $old || strpos($k, $old . '/') === 0) { - $suffix = substr($k, strlen($old)); - $suffix = ltrim((string)$suffix, '/'); - $newKey = $new . ($suffix !== '' ? '/' . $suffix : ''); - $rebased[$newKey] = $rec; - } else { - $rebased[$k] = $rec; + $rebased = []; + foreach ($acl['folders'] as $k => $rec) { + if ($k === $old || strpos($k, $old . '/') === 0) { + $suffix = substr($k, strlen($old)); + $suffix = ltrim((string)$suffix, '/'); + $newKey = $new . ($suffix !== '' ? '/' . $suffix : ''); + $rebased[$newKey] = $rec; + } else { + $rebased[$k] = $rec; + } } + $acl['folders'] = $rebased; + self::save($acl); } - $acl['folders'] = $rebased; - self::save($acl); -} - private static function loadFresh(): array { + private static function loadFresh(): array + { $path = self::path(); if (!is_file($path)) { @mkdir(dirname($path), 0755, true); @@ -94,7 +167,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void 'read' => ['admin'], 'write' => ['admin'], 'share' => ['admin'], - 'read_own'=> [], + 'read_own' => [], 'create' => [], 'upload' => [], 'edit' => [], @@ -130,12 +203,21 @@ public static function renameTree(string $oldFolder, string $newFolder): void $healed = false; foreach ($data['folders'] as $folder => &$rec) { - if (!is_array($rec)) { $rec = []; $healed = true; } + if (!is_array($rec)) { + $rec = []; + $healed = true; + } foreach (self::BUCKETS as $k) { $v = $rec[$k] ?? []; - if (!is_array($v)) { $v = []; $healed = true; } + if (!is_array($v)) { + $v = []; + $healed = true; + } $v = array_values(array_unique(array_map('strval', $v))); - if (($rec[$k] ?? null) !== $v) { $rec[$k] = $v; $healed = true; } + if (($rec[$k] ?? null) !== $v) { + $rec[$k] = $v; + $healed = true; + } } } unset($rec); @@ -145,19 +227,22 @@ public static function renameTree(string $oldFolder, string $newFolder): void return $data; } - private static function save(array $acl): bool { + private static function save(array $acl): bool + { $ok = @file_put_contents(self::path(), json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX) !== false; if ($ok) self::$cache = $acl; return $ok; } - private static function listFor(string $folder, string $key): array { + private static function listFor(string $folder, string $key): array + { $acl = self::$cache ?? self::loadFresh(); $f = $acl['folders'][$folder] ?? null; return is_array($f[$key] ?? null) ? $f[$key] : []; } - public static function ensureFolderRecord(string $folder, string $owner = 'admin'): void { + public static function ensureFolderRecord(string $folder, string $owner = 'admin'): void + { $folder = self::normalizeFolder($folder); $acl = self::$cache ?? self::loadFresh(); if (!isset($acl['folders'][$folder])) { @@ -182,19 +267,23 @@ public static function renameTree(string $oldFolder, string $newFolder): void } } - public static function isAdmin(array $perms = []): bool { + public static function isAdmin(array $perms = []): bool + { if (!empty($_SESSION['isAdmin'])) return true; if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true; if (isset($perms['role']) && (string)$perms['role'] === '1') return true; if (!empty($_SESSION['role']) && (string)$_SESSION['role'] === '1') return true; - if (defined('DEFAULT_ADMIN_USER') && !empty($_SESSION['username']) - && strcasecmp((string)$_SESSION['username'], (string)DEFAULT_ADMIN_USER) === 0) { + if ( + defined('DEFAULT_ADMIN_USER') && !empty($_SESSION['username']) + && strcasecmp((string)$_SESSION['username'], (string)DEFAULT_ADMIN_USER) === 0 + ) { return true; } return false; } - public static function hasGrant(string $user, string $folder, string $cap): bool { + public static function hasGrant(string $user, string $folder, string $cap): bool + { $folder = self::normalizeFolder($folder); $capKey = ($cap === 'owner') ? 'owners' : $cap; $arr = self::listFor($folder, $capKey); @@ -202,35 +291,41 @@ public static function renameTree(string $oldFolder, string $newFolder): void return false; } - public static function isOwner(string $user, array $perms, string $folder): bool { + public static function isOwner(string $user, array $perms, string $folder): bool + { if (self::isAdmin($perms)) return true; return self::hasGrant($user, $folder, 'owners'); } - public static function canManage(string $user, array $perms, string $folder): bool { + public static function canManage(string $user, array $perms, string $folder): bool + { return self::isOwner($user, $perms, $folder); } - public static function canRead(string $user, array $perms, string $folder): bool { + public static function canRead(string $user, array $perms, string $folder): bool + { $folder = self::normalizeFolder($folder); if (self::isAdmin($perms)) return true; return self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'read'); } - public static function canReadOwn(string $user, array $perms, string $folder): bool { + public static function canReadOwn(string $user, array $perms, string $folder): bool + { if (self::canRead($user, $perms, $folder)) return true; return self::hasGrant($user, $folder, 'read_own'); } - public static function canWrite(string $user, array $perms, string $folder): bool { + public static function canWrite(string $user, array $perms, string $folder): bool + { $folder = self::normalizeFolder($folder); if (self::isAdmin($perms)) return true; return self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'write'); } - public static function canShare(string $user, array $perms, string $folder): bool { + public static function canShare(string $user, array $perms, string $folder): bool + { $folder = self::normalizeFolder($folder); if (self::isAdmin($perms)) return true; return self::hasGrant($user, $folder, 'owners') @@ -238,7 +333,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void } // Legacy-only explicit (to avoid breaking existing callers) - public static function explicit(string $folder): array { + public static function explicit(string $folder): array + { $folder = self::normalizeFolder($folder); $acl = self::$cache ?? self::loadFresh(); $rec = $acl['folders'][$folder] ?? []; @@ -257,7 +353,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void } // New: full explicit including granular - public static function explicitAll(string $folder): array { + public static function explicitAll(string $folder): array + { $folder = self::normalizeFolder($folder); $acl = self::$cache ?? self::loadFresh(); $rec = $acl['folders'][$folder] ?? []; @@ -285,7 +382,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void ]; } - public static function upsert(string $folder, array $owners, array $read, array $write, array $share): bool { + public static function upsert(string $folder, array $owners, array $read, array $write, array $share): bool + { $folder = self::normalizeFolder($folder); $acl = self::$cache ?? self::loadFresh(); $existing = $acl['folders'][$folder] ?? ['read_own' => []]; @@ -314,19 +412,23 @@ public static function renameTree(string $oldFolder, string $newFolder): void return self::save($acl); } - public static function applyUserGrantsAtomic(string $user, array $grants): array { + public static function applyUserGrantsAtomic(string $user, array $grants): array + { $user = (string)$user; $path = self::path(); $fh = @fopen($path, 'c+'); if (!$fh) throw new RuntimeException('Cannot open ACL storage'); - if (!flock($fh, LOCK_EX)) { fclose($fh); throw new RuntimeException('Cannot lock ACL storage'); } + if (!flock($fh, LOCK_EX)) { + fclose($fh); + throw new RuntimeException('Cannot lock ACL storage'); + } try { $raw = stream_get_contents($fh); if ($raw === false) $raw = ''; $acl = json_decode($raw, true); - if (!is_array($acl)) $acl = ['folders'=>[], 'groups'=>[]]; + if (!is_array($acl)) $acl = ['folders' => [], 'groups' => []]; if (!isset($acl['folders']) || !is_array($acl['folders'])) $acl['folders'] = []; if (!isset($acl['groups']) || !is_array($acl['groups'])) $acl['groups'] = []; @@ -335,7 +437,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void foreach ($grants as $folder => $caps) { $ff = self::normalizeFolder((string)$folder); if (!isset($acl['folders'][$ff]) || !is_array($acl['folders'][$ff])) $acl['folders'][$ff] = []; - $rec =& $acl['folders'][$ff]; + $rec = &$acl['folders'][$ff]; foreach (self::BUCKETS as $k) { if (!isset($rec[$k]) || !is_array($rec[$k])) $rec[$k] = []; @@ -365,10 +467,16 @@ public static function renameTree(string $oldFolder, string $newFolder): void $sf = !empty($caps['shareFile']) || !empty($caps['share_file']); $sfo = !empty($caps['shareFolder']) || !empty($caps['share_folder']); - if ($m) { $v = true; $w = true; $u = $c = $ed = $rn = $cp = $dl = $ex = $sf = $sfo = true; } + if ($m) { + $v = true; + $w = true; + $u = $c = $ed = $rn = $cp = $dl = $ex = $sf = $sfo = true; + } if ($u && !$v && !$vo) $vo = true; //if ($s && !$v) $v = true; - if ($w) { $c = $u = $ed = $rn = $cp = $dl = $ex = true; } + if ($w) { + $c = $u = $ed = $rn = $cp = $dl = $ex = true; + } if ($m) $rec['owners'][] = $user; if ($v) $rec['read'][] = $user; @@ -385,7 +493,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void if ($dl) $rec['delete'][] = $user; if ($ex) $rec['extract'][] = $user; if ($sf) $rec['share_file'][] = $user; - if ($sfo)$rec['share_folder'][] = $user; + if ($sfo) $rec['share_folder'][] = $user; foreach (self::BUCKETS as $k) { $rec[$k] = array_values(array_unique(array_map('strval', $rec[$k]))); @@ -409,90 +517,102 @@ public static function renameTree(string $oldFolder, string $newFolder): void } } -// --- Granular write family ----------------------------------------------- + // --- Granular write family ----------------------------------------------- -public static function canCreate(string $user, array $perms, string $folder): bool { - $folder = self::normalizeFolder($folder); - if (self::isAdmin($perms)) return true; - return self::hasGrant($user, $folder, 'owners') - || self::hasGrant($user, $folder, 'create') - || self::hasGrant($user, $folder, 'write'); -} + public static function canCreate(string $user, array $perms, string $folder): bool + { + $folder = self::normalizeFolder($folder); + if (self::isAdmin($perms)) return true; + return self::hasGrant($user, $folder, 'owners') + || self::hasGrant($user, $folder, 'create') + || self::hasGrant($user, $folder, 'write'); + } -public static function canCreateFolder(string $user, array $perms, string $folder): bool { - $folder = self::normalizeFolder($folder); - if (self::isAdmin($perms)) return true; - // Only owners/managers can create subfolders under $folder - return self::hasGrant($user, $folder, 'owners'); -} + public static function canCreateFolder(string $user, array $perms, string $folder): bool + { + $folder = self::normalizeFolder($folder); + if (self::isAdmin($perms)) return true; + // Only owners/managers can create subfolders under $folder + return self::hasGrant($user, $folder, 'owners'); + } -public static function canUpload(string $user, array $perms, string $folder): bool { - $folder = self::normalizeFolder($folder); - if (self::isAdmin($perms)) return true; - return self::hasGrant($user, $folder, 'owners') - || self::hasGrant($user, $folder, 'upload') - || self::hasGrant($user, $folder, 'write'); -} + public static function canUpload(string $user, array $perms, string $folder): bool + { + $folder = self::normalizeFolder($folder); + if (self::isAdmin($perms)) return true; + return self::hasGrant($user, $folder, 'owners') + || self::hasGrant($user, $folder, 'upload') + || self::hasGrant($user, $folder, 'write'); + } -public static function canEdit(string $user, array $perms, string $folder): bool { - $folder = self::normalizeFolder($folder); - if (self::isAdmin($perms)) return true; - return self::hasGrant($user, $folder, 'owners') - || self::hasGrant($user, $folder, 'edit') - || self::hasGrant($user, $folder, 'write'); -} + public static function canEdit(string $user, array $perms, string $folder): bool + { + $folder = self::normalizeFolder($folder); + if (self::isAdmin($perms)) return true; + return self::hasGrant($user, $folder, 'owners') + || self::hasGrant($user, $folder, 'edit') + || self::hasGrant($user, $folder, 'write'); + } -public static function canRename(string $user, array $perms, string $folder): bool { - $folder = self::normalizeFolder($folder); - if (self::isAdmin($perms)) return true; - return self::hasGrant($user, $folder, 'owners') - || self::hasGrant($user, $folder, 'rename') - || self::hasGrant($user, $folder, 'write'); -} + public static function canRename(string $user, array $perms, string $folder): bool + { + $folder = self::normalizeFolder($folder); + if (self::isAdmin($perms)) return true; + return self::hasGrant($user, $folder, 'owners') + || self::hasGrant($user, $folder, 'rename') + || self::hasGrant($user, $folder, 'write'); + } -public static function canCopy(string $user, array $perms, string $folder): bool { - $folder = self::normalizeFolder($folder); - if (self::isAdmin($perms)) return true; - return self::hasGrant($user, $folder, 'owners') - || self::hasGrant($user, $folder, 'copy') - || self::hasGrant($user, $folder, 'write'); -} + public static function canCopy(string $user, array $perms, string $folder): bool + { + $folder = self::normalizeFolder($folder); + if (self::isAdmin($perms)) return true; + return self::hasGrant($user, $folder, 'owners') + || self::hasGrant($user, $folder, 'copy') + || self::hasGrant($user, $folder, 'write'); + } -public static function canMove(string $user, array $perms, string $folder): bool { - $folder = self::normalizeFolder($folder); - if (self::isAdmin($perms)) return true; - return self::ownsFolderOrAncestor($user, $perms, $folder); -} + public static function canMove(string $user, array $perms, string $folder): bool + { + $folder = self::normalizeFolder($folder); + if (self::isAdmin($perms)) return true; + return self::ownsFolderOrAncestor($user, $perms, $folder); + } -public static function canMoveFolder(string $user, array $perms, string $folder): bool { - $folder = self::normalizeFolder($folder); - if (self::isAdmin($perms)) return true; - return self::ownsFolderOrAncestor($user, $perms, $folder); -} + public static function canMoveFolder(string $user, array $perms, string $folder): bool + { + $folder = self::normalizeFolder($folder); + if (self::isAdmin($perms)) return true; + return self::ownsFolderOrAncestor($user, $perms, $folder); + } -public static function canDelete(string $user, array $perms, string $folder): bool { - $folder = self::normalizeFolder($folder); - if (self::isAdmin($perms)) return true; - return self::hasGrant($user, $folder, 'owners') - || self::hasGrant($user, $folder, 'delete') - || self::hasGrant($user, $folder, 'write'); -} + public static function canDelete(string $user, array $perms, string $folder): bool + { + $folder = self::normalizeFolder($folder); + if (self::isAdmin($perms)) return true; + return self::hasGrant($user, $folder, 'owners') + || self::hasGrant($user, $folder, 'delete') + || self::hasGrant($user, $folder, 'write'); + } + + public static function canExtract(string $user, array $perms, string $folder): bool + { + $folder = self::normalizeFolder($folder); + if (self::isAdmin($perms)) return true; + return self::hasGrant($user, $folder, 'owners') + || self::hasGrant($user, $folder, 'extract') + || self::hasGrant($user, $folder, 'write'); + } -public static function canExtract(string $user, array $perms, string $folder): bool { - $folder = self::normalizeFolder($folder); - if (self::isAdmin($perms)) return true; - return self::hasGrant($user, $folder, 'owners') - || self::hasGrant($user, $folder, 'extract') - || self::hasGrant($user, $folder, 'write'); -} - /** Sharing: files use share, folders require share + full-view. */ - public static function canShareFile(string $user, array $perms, string $folder): bool { + public static function canShareFile(string $user, array $perms, string $folder): bool + { $folder = self::normalizeFolder($folder); if (self::isAdmin($perms)) return true; return self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share'); } - public static function canShareFolder(string $user, array $perms, string $folder): bool { + public static function canShareFolder(string $user, array $perms, string $folder): bool + { $folder = self::normalizeFolder($folder); if (self::isAdmin($perms)) return true; $can = self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share'); diff --git a/src/models/FolderMeta.php b/src/models/FolderMeta.php new file mode 100644 index 0000000..b2662f4 --- /dev/null +++ b/src/models/FolderMeta.php @@ -0,0 +1,90 @@ +$folder, 'color'=>$map[$folder] ?? null]; + } + + /** Migrate color entries for a whole subtree (used by move/rename) */ + public static function migrateSubtree(string $source, string $target): array { + $src = self::normalizeFolder($source); + $dst = self::normalizeFolder($target); + if ($src === 'root') return ['changed'=>false, 'moved'=>0]; + + $map = self::getMap(); + if (!$map) return ['changed'=>false, 'moved'=>0]; + + $new = $map; + $moved = 0; + + foreach ($map as $key => $hex) { + $isSelf = ($key === $src); + $isSub = str_starts_with($key.'/', $src.'/'); + if (!$isSelf && !$isSub) continue; + + unset($new[$key]); + $suffix = substr($key, strlen($src)); // '' or '/child/...' + $newKey = $dst === 'root' ? ltrim($suffix,'/') : rtrim($dst,'/') . $suffix; + $new[$newKey] = $hex; + $moved++; + } + + if ($moved) self::writeMap($new); + return ['changed'=> (bool)$moved, 'moved'=> $moved]; + } +} \ No newline at end of file
This folder is protected by a password. Please enter the password to view its contents.
This folder is empty.