release(v1.6.7): Folder Move feature, stable DnD persistence, safer uploads, and ACL/UI polish

This commit is contained in:
Ryan
2025-10-25 02:16:01 -04:00
committed by GitHub
parent b5610cf156
commit 2739925f0b
15 changed files with 915 additions and 224 deletions

View File

@@ -1,5 +1,55 @@
# Changelog
## Changes 10/25/2025 (v1.6.7)
release(v1.6.7): Folder Move feature, stable DnD persistence, safer uploads, and ACL/UI polish
### 📂 Folder Move (new major feature)
**Drag & Drop to move folder, use context menu or Move Folder button**
- Added **Move Folder** support across backend and UI.
- New API endpoint: `public/api/folder/moveFolder.php`
- Controller and ACL updates to validate scope, ownership, and permissions.
- Non-admins can only move within folders they own.
- `ACL::renameTree()` re-keys all subtree ACLs on folder rename/move.
- Introduced new capabilities:
- `canMoveFolder`
- `canMove` (UI alias for backward compatibility)
- New “Move Folder” button + modal in the UI with full i18n strings (`i18n.js`).
- Action button styling and tooltip consistency for all folder actions.
### 🧱 Drag & Drop / Layout Improvements
- Fixed **random sidebar → top zone jumps** on refresh.
- Cards/panels now **persist exactly where you placed them** (`userZonesSnapshot`)
— no unwanted repositioning unless the window is resized below the small-screen threshold.
- Added hysteresis around the 1205 px breakpoint to prevent flicker when resizing.
- Eliminated the 50 px “ghost” gutter with `clampSidebarWhenEmpty()`:
- Sidebar no longer reserves space when collapsed or empty.
- Temporarily “unclamps” during drag so drop targets remain accurate and full-width.
- Removed forced 800 px height on drag highlight; uses natural flex layout now.
- General layout polish — smoother transitions when toggling *Hide/Show Panels*.
### ☁️ Uploads & UX
- Stronger folder sanitization and safer base-path handling.
- Fixed subfolder creation when uploading directories (now builds under correct parent).
- Improved chunk error handling and metadata key correctness.
- Clearer success/failure toasts and accurate filename display from server responses.
### 🔐 Permissions / ACL
- Simplified file rename checks — now rely solely on granular `ACL::canRename()`.
- Updated capability lists to include move/rename operations consistently.
### 🌐 UI / i18n Enhancements
- Added i18n strings for new “Move Folder” prompts, modals, and tooltips.
- Minor UI consistency tweaks: button alignment, focus states, reduced-motion support.
---
## Changes 10/24/2025 (v1.6.6)
release(v1.6.6): header-mounted toggle, dark-mode polish, persistent layout, and ACL fix

View File

@@ -185,6 +185,7 @@ $canDelete = $gDeleteBase && !$readOnly && $inScope;
$canReceive = ($gUploadBase || $gCreateBase || $canManageBase) && !$readOnly && $inScope;
// Back-compat: expose as canMoveIn (used by toolbar/context-menu/drag&drop)
$canMoveIn = $canReceive;
$canMoveAlias = $canMoveIn;
$canEdit = $gEditBase && !$readOnly && $inScope;
$canCopy = $gCopyBase && !$readOnly && $inScope;
$canExtract = $gExtractBase && !$readOnly && $inScope;
@@ -200,6 +201,12 @@ if ($isRoot) {
$canRename = false;
$canDelete = false;
$canShareFoldEff = false;
$canMoveFolder = false;
}
if (!$isRoot) {
$canMoveFolder = (ACL::canManage($username, $perms, $folder) || ACL::isOwner($username, $perms, $folder))
&& !$readOnly;
}
$owner = null;
@@ -225,6 +232,8 @@ echo json_encode([
'canRename' => $canRename,
'canDelete' => $canDelete,
'canMoveIn' => $canMoveIn,
'canMove' => $canMoveAlias,
'canMoveFolder'=> $canMoveFolder,
'canEdit' => $canEdit,
'canCopy' => $canCopy,
'canExtract' => $canExtract,

View File

@@ -0,0 +1,9 @@
<?php
// public/api/folder/moveFolder.php
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
$controller = new FolderController();
$controller->moveFolder();

View File

@@ -1046,11 +1046,6 @@ label {
display: none;
}
#createFolderBtn {
margin-top: 0px !important;
height: 40px !important;
font-size: 1rem;
}
.folder-actions {
display: flex;
@@ -1058,6 +1053,7 @@ label {
padding-left: 8px;
align-items: center;
white-space: nowrap;
padding-top: 10px;
}
@media (min-width: 600px) and (max-width: 992px) {
@@ -1066,6 +1062,70 @@ label {
}
}
.folder-actions .btn {
padding: 10px 12px;
font-size: 0.85rem;
line-height: 1.1;
border-radius: 6px;
}
.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;
transform: scale(1);
transform-origin: center;
transition: transform 120ms ease, box-shadow 120ms ease;
will-change: transform;
}
.folder-actions .material-icons {
font-size: 24px;
vertical-align: -2px;
transition: transform 120ms ease;
}
.folder-actions .btn:hover,
.folder-actions .btn:focus-visible {
transform: scale(1.06);
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
}
.folder-actions .btn:hover .material-icons,
.folder-actions .btn:focus-visible .material-icons {
transform: scale(1.05);
}
.folder-actions .btn:focus-visible {
outline: 2px solid rgba(33,150,243,0.6);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.folder-actions .btn,
.folder-actions .material-icons {
transition: none;
}
}
#moveFolderBtn {
background-color: #ff9800;
border-color: #ff9800;
color: #fff;
}
.row-selected {
background-color: #f2f2f2 !important;
}

View File

@@ -286,9 +286,27 @@
</div>
</div>
</div>
<button id="moveFolderBtn" class="btn btn-warning ml-2" data-i18n-title="move_folder">
<i class="material-icons">drive_file_move</i>
</button>
<!-- MOVE FOLDER MODAL (place near your other folder modals) -->
<div id="moveFolderModal" class="modal" style="display:none;">
<div class="modal-content">
<h4 data-i18n-key="move_folder_title">Move Folder</h4>
<p data-i18n-key="move_folder_message">Select a destination folder to move the current folder
into:</p>
<select id="moveFolderTarget" class="form-control modal-input"></select>
<div class="modal-footer" style="margin-top:15px; text-align:right;">
<button id="cancelMoveFolder" class="btn btn-secondary"
data-i18n-key="cancel">Cancel</button>
<button id="confirmMoveFolder" class="btn btn-primary" data-i18n-key="move">Move</button>
</div>
</div>
</div>
<button id="renameFolderBtn" class="btn btn-warning ml-2" data-i18n-title="rename_folder">
<i class="material-icons">drive_file_rename_outline</i>
</button>
<div id="renameFolderModal" class="modal">
<div class="modal-content">
<h4 data-i18n-key="rename_folder_title">Rename Folder</h4>
@@ -391,14 +409,12 @@
data-i18n-key="download_zip">Download ZIP</button>
<button id="extractZipBtn" class="btn action-btn btn-sm btn-info" data-i18n-title="extract_zip"
data-i18n-key="extract_zip_button">Extract Zip</button>
<div id="createDropdown" class="dropdown-container" style="position:relative; display:inline-block;">
<button id="createBtn" class="btn action-btn" data-i18n-key="create">
${t('create')} <span class="material-icons" style="font-size:16px;vertical-align:middle;">arrow_drop_down</span>
</button>
<ul
id="createMenu"
class="dropdown-menu"
style="
<div id="createDropdown" class="dropdown-container" style="position:relative; display:inline-block;">
<button id="createBtn" class="btn action-btn" data-i18n-key="create">
${t('create')} <span class="material-icons"
style="font-size:16px;vertical-align:middle;">arrow_drop_down</span>
</button>
<ul id="createMenu" class="dropdown-menu" style="
display: none;
position: absolute;
top: 100%;
@@ -411,27 +427,23 @@
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
z-index: 1000;
min-width: 140px;
"
>
<li id="createFileOption" class="dropdown-item" data-i18n-key="create_file" style="padding:8px 12px; cursor:pointer;">
${t('create_file')}
</li>
<li id="createFolderOption" class="dropdown-item" data-i18n-key="create_folder" style="padding:8px 12px; cursor:pointer;">
${t('create_folder')}
</li>
</ul>
</div>
">
<li id="createFileOption" class="dropdown-item" data-i18n-key="create_file"
style="padding:8px 12px; cursor:pointer;">
${t('create_file')}
</li>
<li id="createFolderOption" class="dropdown-item" data-i18n-key="create_folder"
style="padding:8px 12px; cursor:pointer;">
${t('create_folder')}
</li>
</ul>
</div>
<!-- Create File Modal -->
<div id="createFileModal" class="modal" style="display:none;">
<div class="modal-content">
<h4 data-i18n-key="create_new_file">Create New File</h4>
<input
type="text"
id="createFileNameInput"
class="form-control"
placeholder="Enter filename…"
data-i18n-placeholder="newfile_placeholder"
/>
<input type="text" id="createFileNameInput" class="form-control" placeholder="Enter filename…"
data-i18n-placeholder="newfile_placeholder" />
<div class="modal-footer" style="margin-top:1rem; text-align:right;">
<button id="cancelCreateFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
<button id="confirmCreateFile" class="btn btn-primary" data-i18n-key="create">Create</button>

View File

@@ -59,7 +59,7 @@ function onShareFileToggle(row, checked) {
}
function onWriteToggle(row, checked) {
const caps = ["create","upload","edit","rename","copy","move","delete","extract"];
const caps = ["create","upload","edit","rename","copy","delete","extract"];
caps.forEach(c => {
const box = qs(row, `input[data-cap="${c}"]`);
if (box) box.checked = checked;
@@ -683,25 +683,32 @@ function renderFolderGrantsUI(username, container, folders, grants) {
// toolbar
const toolbar = document.createElement('div');
toolbar.className = 'folder-access-toolbar';
toolbar.innerHTML = `
<input type="text" class="form-control" style="max-width:220px;" placeholder="${tf('search_folders', 'Search folders')}" />
<label class="muted" title="${tf('view_all_help', 'See all files in this folder (everyones files)')}">
<input type="checkbox" data-bulk="view" /> ${tf('view_all', 'View (all)')}
</label>
<label class="muted" title="${tf('view_own_help', 'See only files you uploaded in this folder')}">
<input type="checkbox" data-bulk="viewOwn" /> ${tf('view_own', 'View (own)')}
</label>
<label class="muted" title="${tf('write_help', 'Create/upload files and edit/rename/copy/delete items in this folder')}">
<input type="checkbox" data-bulk="write" /> ${tf('write_full', 'Write (upload/edit/delete)')}
</label>
<label class="muted" title="${tf('manage_help', 'Owner-level: can grant access; implies View (all)/Create Folder/Rename Folder/Move Files/Share Folder')}">
<input type="checkbox" data-bulk="manage" /> ${tf('manage', 'Manage')}
</label>
<label class="muted" title="${tf('share_help', 'Create/manage share links; implies View (all)')}">
<input type="checkbox" data-bulk="share" /> ${tf('share', 'Share')}
</label>
<span class="muted">(${tf('applies_to_filtered', 'applies to filtered list')})</span>
`;
toolbar.innerHTML = `
<input type="text" class="form-control" style="max-width:220px;"
placeholder="${tf('search_folders', 'Search folders')}" />
<label class="muted" title="${tf('view_all_help', 'See all files in this folder (everyones files)')}">
<input type="checkbox" data-bulk="view" /> ${tf('view_all', 'View (all)')}
</label>
<label class="muted" title="${tf('view_own_help', 'See only files you uploaded in this folder')}">
<input type="checkbox" data-bulk="viewOwn" /> ${tf('view_own', 'View (own files)')}
</label>
<label class="muted" title="${tf('write_help', 'File-level: upload, edit, rename, copy, delete, extract ZIPs')}">
<input type="checkbox" data-bulk="write" /> ${tf('write_full', 'Write (file ops)')}
</label>
<label class="muted" title="${tf('manage_help', 'Folder-level (owner): can create/rename/move folders and grant access; implies View (all)')}">
<input type="checkbox" data-bulk="manage" /> ${tf('manage', 'Manage (folder owner)')}
</label>
<label class="muted" title="${tf('share_help', 'Create/manage share links; implies View (all)')}">
<input type="checkbox" data-bulk="share" /> ${tf('share', 'Share')}
</label>
<span class="muted">(${tf('applies_to_filtered', 'applies to filtered list')})</span>
`;
container.appendChild(toolbar);
const list = document.createElement('div');
@@ -709,30 +716,55 @@ function renderFolderGrantsUI(username, container, folders, grants) {
container.appendChild(list);
const headerHtml = `
<div class="folder-access-header">
<div class="folder-access-header">
<div class="folder-cell" title="${tf('folder_help','Folder path within FileRise')}">
${tf('folder','Folder')}
</div>
<div class="perm-col" title="${tf('view_all_help', 'See all files in this folder (everyones files)')}">${tf('view_all', 'View (all)')}</div>
<div class="perm-col" title="${tf('view_own_help', 'See only files you uploaded in this folder')}">${tf('view_own', 'View (own)')}</div>
<div class="perm-col" title="${tf('write_help', 'Meta: toggles all write operations (below) on/off for this row')}">${tf('write_full', 'Write')}</div>
<div class="perm-col" title="${tf('manage_help', 'Owner-level: can grant access; implies View (all)/Create Folder/Rename Folder/Move Files/Share Folder')}">${tf('manage', 'Manage')}</div>
<div class="perm-col" title="${tf('create_help', 'Create empty files')}">${tf('create', 'Create')}</div>
<div class="perm-col" title="${tf('upload_help', 'Upload files to this folder')}">${tf('upload', 'Upload')}</div>
<div class="perm-col" title="${tf('edit_help', 'Edit file contents')}">${tf('edit', 'Edit')}</div>
<div class="perm-col" title="${tf('rename_help', 'Rename files')}">${tf('rename', 'Rename')}</div>
<div class="perm-col" title="${tf('copy_help', 'Copy files')}">${tf('copy', 'Copy')}</div>
<div class="perm-col" title="${tf('move_help', 'Move files: requires Manage')}">${tf('move', 'Move')}</div>
<div class="perm-col" title="${tf('delete_help', 'Delete files/folders')}">${tf('delete', 'Delete')}</div>
<div class="perm-col" title="${tf('extract_help', 'Extract ZIP archives')}">${tf('extract', 'Extract ZIP')}</div>
<div class="perm-col" title="${tf('share_file_help', 'Create share links for files')}">${tf('share_file', 'Share File')}</div>
<div class="perm-col" title="${tf('share_folder_help', 'Create share links for folders (requires View all)')}">${tf('share_folder', 'Share Folder')}</div>
<div class="perm-col" title="${tf('view_all_help', 'See all files in this folder (everyones files)')}">
${tf('view_all', 'View (all)')}
</div>
<div class="perm-col" title="${tf('view_own_help', 'See only files you uploaded in this folder')}">
${tf('view_own', 'View (own)')}
</div>
<div class="perm-col" title="${tf('write_help', 'Meta: toggles all file-level operations below')}">
${tf('write_full', 'Write')}
</div>
<div class="perm-col" title="${tf('manage_help', 'Folder owner: can create/rename/move folders and grant access; implies View (all)')}">
${tf('manage', 'Manage')}
</div>
<div class="perm-col" title="${tf('create_help', 'Create empty file')}">
${tf('create', 'Create File')}
</div>
<div class="perm-col" title="${tf('upload_help', 'Upload a file into this folder')}">
${tf('upload', 'Upload File')}
</div>
<div class="perm-col" title="${tf('edit_help', 'Edit file contents')}">
${tf('edit', 'Edit File')}
</div>
<div class="perm-col" title="${tf('rename_help', 'Rename a file')}">
${tf('rename', 'Rename File')}
</div>
<div class="perm-col" title="${tf('copy_help', 'Copy a file')}">
${tf('copy', 'Copy File')}
</div>
<div class="perm-col" title="${tf('delete_help', 'Delete a file')}">
${tf('delete', 'Delete File')}
</div>
<div class="perm-col" title="${tf('extract_help', 'Extract ZIP archives')}">
${tf('extract', 'Extract ZIP')}
</div>
<div class="perm-col" title="${tf('share_file_help', 'Create share links for files')}">
${tf('share_file', 'Share File')}
</div>
<div class="perm-col" title="${tf('share_folder_help', 'Create share links for folders (requires Manage + View (all))')}">
${tf('share_folder', 'Share Folder')}
</div>
</div>`;
function rowHtml(folder) {
const g = grants[folder] || {};
const name = folder === 'root' ? '(Root)' : folder;
const writeMetaChecked = !!(g.create || g.upload || g.edit || g.rename || g.copy || g.move || g.delete || g.extract);
const writeMetaChecked = !!(g.create || g.upload || g.edit || g.rename || g.copy || g.delete || g.extract);
const shareFolderDisabled = !g.view;
return `
<div class="folder-access-row" data-folder="${folder}">
@@ -752,7 +784,6 @@ function renderFolderGrantsUI(username, container, folders, grants) {
<div class="perm-col"><input type="checkbox" data-cap="edit" ${g.edit ? 'checked' : ''}></div>
<div class="perm-col"><input type="checkbox" data-cap="rename" ${g.rename ? 'checked' : ''}></div>
<div class="perm-col"><input type="checkbox" data-cap="copy" ${g.copy ? 'checked' : ''}></div>
<div class="perm-col"><input type="checkbox" data-cap="move" ${g.move ? 'checked' : ''}></div>
<div class="perm-col"><input type="checkbox" data-cap="delete" ${g.delete ? 'checked' : ''}></div>
<div class="perm-col"><input type="checkbox" data-cap="extract" ${g.extract ? 'checked' : ''}></div>
<div class="perm-col"><input type="checkbox" data-cap="shareFile" ${g.shareFile ? 'checked' : ''}></div>
@@ -788,7 +819,7 @@ function renderFolderGrantsUI(username, container, folders, grants) {
if (v) v.checked = true;
if (w) w.checked = true;
if (vo) { vo.checked = false; vo.disabled = true; }
['create','upload','edit','rename','copy','move','delete','extract','shareFile','shareFolder']
['create','upload','edit','rename','copy','delete','extract','shareFile','shareFolder']
.forEach(c => { const cb = qs(row, `input[data-cap="${c}"]`); if (cb) cb.checked = true; });
setRowDisabled(row, true);
const tag = row.querySelector('.inherited-tag');
@@ -888,7 +919,7 @@ function renderFolderGrantsUI(username, container, folders, grants) {
const w = r.querySelector('input[data-cap="write"]');
const vo = r.querySelector('input[data-cap="viewOwn"]');
const boxes = [
'create','upload','edit','rename','copy','move','delete','extract','shareFile','shareFolder'
'create','upload','edit','rename','copy','delete','extract','shareFile','shareFolder'
].map(c => r.querySelector(`input[data-cap="${c}"]`));
if (m) m.checked = checked;
if (v) v.checked = checked;

View File

@@ -8,7 +8,7 @@
const MEDIUM_MIN = 1205; // matches your small-screen cutoff
const MEDIUM_MAX = 1600; // tweak as you like
const TOGGLE_TOP_PX = 10;
const TOGGLE_TOP_PX = 8;
const TOGGLE_LEFT_PX = 100;
const TOGGLE_ICON_OPEN = 'view_sidebar';
@@ -19,6 +19,13 @@ const KNOWN_CARD_IDS = ['uploadCard', 'folderManagementCard'];
const CARD_IDS = ['uploadCard', 'folderManagementCard'];
// --- NEW: separate user snapshot so refresh restores *manual* placements only ---
const USER_SNAPSHOT_KEY = 'userZonesSnapshot'; // { cardId: 'sidebarDropArea'|'leftCol'|'rightCol' }
function hasUserSnapshot() {
try { const s = JSON.parse(localStorage.getItem(USER_SNAPSHOT_KEY) || '{}'); return !!s && Object.keys(s).length > 0; } catch { return false; }
}
function isDarkMode() {
return document.body.classList.contains('dark-mode');
}
@@ -54,6 +61,16 @@ function snapshotZoneLocations() {
localStorage.setItem('zonesSnapshot', JSON.stringify(snap));
}
// NEW: Save where the user *manually* placed cards (used on normal refresh).
function snapshotUserZones() {
const snap = {};
getKnownCards().forEach(card => {
const p = card.parentNode;
snap[card.id] = p && p.id ? p.id : '';
});
localStorage.setItem(USER_SNAPSHOT_KEY, JSON.stringify(snap));
}
// Move a card to default expanded spot (your request: sidebar is default).
function moveCardToSidebarDefault(card) {
const sidebar = getSidebar();
@@ -75,6 +92,56 @@ function stripHeaderArtifacts(card) {
}
}
// Kill the 50px ghost gutter when the sidebar isn't participating in layout.
function clampSidebarWhenEmpty() {
const sidebar = getSidebar();
if (!sidebar) return;
const sidebarHasCards = hasSidebarCards();
const collapsed = isZonesCollapsed();
// Sidebar should not take space if it's collapsed OR has no cards.
const shouldHideSidebarSpace = collapsed || !sidebarHasCards;
if (shouldHideSidebarSpace) {
// Make sure it takes absolutely no horizontal space.
sidebar.style.display = 'none'; // don't render at all
sidebar.style.width = '0px';
sidebar.style.minWidth = '0px';
sidebar.style.margin = '0';
sidebar.style.padding = '0';
sidebar.style.flex = '0 0 0px';
// if you add/remove highlight elsewhere, also ensure it's not forcing size
sidebar.classList.remove('active');
} else {
// Let your CSS control it when it's actually visible/has cards.
sidebar.style.width = '';
sidebar.style.minWidth = '';
sidebar.style.margin = '';
sidebar.style.padding = '';
sidebar.style.flex = '';
// display is already decided by updateSidebarVisibility/applyZonesCollapsed
}
}
// Let the sidebar become a real drop target during drag, even if empty.
function unclampSidebarForDrag() {
const sidebar = getSidebar();
if (!sidebar) return;
// only un-clamp if panels are not collapsed
if (!isZonesCollapsed()) {
sidebar.style.display = 'block';
// give it a sensible min width so the highlight looks right
sidebar.style.minWidth = '280px';
// never force a fixed height here; let CSS layout handle it
sidebar.style.width = '';
sidebar.style.flex = ''; // don't lock flex while dragging
sidebar.style.margin = '';
sidebar.style.padding = '';
}
}
// Restore cards after “expand” (toggle off) or after refresh.
// - If we have a snapshot, use it.
// - If not, put all cards in the sidebar (your default).
@@ -152,8 +219,10 @@ function removeHeaderIconForCard(card) {
}
}
// Apply *user* snapshot on normal load (not expand) to preserve manual placement.
function applySnapshotIfPresent() {
const snap = readZonesSnapshot();
let snap = {};
try { snap = JSON.parse(localStorage.getItem(USER_SNAPSHOT_KEY) || '{}'); } catch { snap = {}; }
const keys = Object.keys(snap || {});
if (!keys.length) return false;
@@ -231,21 +300,39 @@ function moveAllSidebarCardsToTop() {
}
// New: enforce responsive behavior (sidebar disabled on small screens)
// Add hysteresis to avoid flapping near threshold
let __lastIsSmall = null;
let __lastWidth = null;
const SMALL_ENTER = MEDIUM_MIN - 16; // enter small below this
const SMALL_EXIT = MEDIUM_MIN + 16; // leave small above this
function enforceResponsiveZones() {
const isSmall = isSmallScreen();
const w = window.innerWidth;
const prevSmall = __lastIsSmall;
let nowSmall;
if (__lastWidth == null) {
nowSmall = w < MEDIUM_MIN;
} else if (prevSmall === true) {
nowSmall = !(w >= SMALL_EXIT);
} else if (prevSmall === false) {
nowSmall = (w < SMALL_ENTER);
} else {
nowSmall = w < MEDIUM_MIN;
}
__lastWidth = w;
const sidebar = getSidebar();
const topZone = getTopZone();
if (isSmall && __lastIsSmall !== true) {
if (nowSmall && prevSmall !== true) {
// entering small: remember what was in sidebar, move them up, hide sidebar
snapshotSidebarCardsForResponsive();
moveAllSidebarCardsToTop();
if (sidebar) sidebar.style.display = 'none';
if (topZone) topZone.style.display = ''; // ensure visible
__lastIsSmall = true;
} else if (!isSmall && __lastIsSmall !== false) {
} else if (!nowSmall && prevSmall !== false) {
// leaving small: restore only what used to be in the sidebar
const ids = readResponsiveSnapshot();
const sb = getSidebar();
@@ -309,10 +396,10 @@ function setZonesCollapsed(collapsed) {
if (collapsed) {
// Remember where cards were, then show them as header icons
snapshotZoneLocations();
collapseCardsToHeader(); // your existing helper that calls insertCardInHeader(...)
snapshotZoneLocations(); // original snapshot used only for collapse/expand
collapseCardsToHeader(); // your existing helper that calls insertCardInHeader(...)
} else {
// Expand: bring cards back
// Expand: bring cards back (from the collapse snapshot)
restoreCardsFromSnapshot();
// Ensure zones are visible right away after expand
@@ -324,6 +411,7 @@ function setZonesCollapsed(collapsed) {
ensureZonesToggle();
updateZonesToggleUI();
clampSidebarWhenEmpty();
}
function applyZonesCollapsed() {
@@ -570,6 +658,7 @@ export function loadSidebarOrder() {
const orderStr = localStorage.getItem('sidebarOrder');
const headerOrderStr = localStorage.getItem('headerOrder');
// Restore user's last manual placement on normal load
if (applySnapshotIfPresent()) {
updateTopZoneLayout();
updateSidebarVisibility();
@@ -577,6 +666,23 @@ export function loadSidebarOrder() {
ensureZonesToggle();
return;
}
// If no user snapshot exists and we're not on small screens,
// but the cards are currently in the top zone, default them to the sidebar once.
if (!hasUserSnapshot() && !isSmallScreen() && hasTopZoneCards() && !hasSidebarCards()) {
const sb = getSidebar();
if (sb) {
['uploadCard','folderManagementCard'].forEach(id => {
const c = document.getElementById(id);
if (c && !sb.contains(c)) {
sb.appendChild(c);
c.style.width = '100%';
}
});
snapshotUserZones(); // persist this as the user's baseline
updateSidebarVisibility();
updateTopZoneLayout();
}
}
// Only apply the one-time default if *not* initialized yet
if (!defaultAlready &&
@@ -692,6 +798,7 @@ function updateSidebarVisibility() {
localStorage.setItem('layoutDefaultApplied_v1', '1');
ensureZonesToggle();
clampSidebarWhenEmpty();
}
// NEW: Save header order to localStorage.
@@ -733,6 +840,7 @@ function updateTopZoneLayout() {
// hide whole top row when empty (kills the gap)
if (topZone) topZone.style.display = (hasUpload || hasFolder) ? '' : 'none';
clampSidebarWhenEmpty();
}
// When a card is being dragged, if the top drop zone is empty, set its min-height.
@@ -799,6 +907,10 @@ function insertCardInSidebar(card, event) {
// SAVE order & refresh minimal UI, but DO NOT collapse/restore here:
saveSidebarOrder();
// NEW: persist user manual placement (used on normal refresh)
snapshotUserZones();
updateSidebarVisibility();
ensureZonesToggle();
updateZonesToggleUI();
@@ -1081,13 +1193,15 @@ export function initDragAndDrop() {
showTopZoneWhileDragging();
const sidebar = getSidebar();
if (sidebar) {
sidebar.classList.add('active');
sidebar.style.display = isZonesCollapsed() ? 'none' : 'block';
sidebar.classList.add('highlight');
sidebar.style.height = '800px';
sidebar.style.minWidth = '280px';
}
if (sidebar) {
unclampSidebarForDrag(); // <— NEW
sidebar.classList.add('active');
sidebar.classList.add('highlight');
// keep it visible, but don't force a fixed height
sidebar.style.removeProperty('height'); // <— no 800px box
// ensure it's actually visible if not collapsed
if (!isZonesCollapsed()) sidebar.style.display = 'block';
}
showHeaderDropZone();
const topZone = getTopZone();
@@ -1169,7 +1283,6 @@ export function initDragAndDrop() {
const sidebarElem = getSidebar();
if (sidebarElem) {
const rect = sidebarElem.getBoundingClientRect();
const dropZoneBottom = rect.top + 800; // Virtual drop zone height.
if (
e.clientX >= rect.left &&
e.clientX <= rect.right &&
@@ -1208,6 +1321,9 @@ export function initDragAndDrop() {
setTimeout(() => {
card.style.removeProperty('width');
}, 210);
// NEW: persist user manual placement
snapshotUserZones();
}
}
}
@@ -1224,6 +1340,7 @@ export function initDragAndDrop() {
) {
insertCardInHeader(card, e);
droppedInHeader = true;
// header mode is transient; do not overwrite userZones here
}
}
@@ -1257,10 +1374,16 @@ export function initDragAndDrop() {
updateTopZoneLayout();
updateSidebarVisibility();
clampSidebarWhenEmpty();
if (sidebar) {
sidebar.classList.remove('highlight');
sidebar.style.height = '';
sidebar.style.minWidth = '';
}
hideHeaderDropZone();
cleanupTopZoneAfterDrop();
snapshotZoneLocations();
snapshotZoneLocations(); // keep original (collapse/expand)
const tz = getTopZone();
if (tz) tz.style.minHeight = '';
}

View File

@@ -103,6 +103,7 @@ async function applyFolderCapabilities(folder) {
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('deleteFolderBtn'), !isRoot && !!caps.canDelete);
setControlEnabled(document.getElementById('shareFolderBtn'), !isRoot && !!caps.canShareFolder);
@@ -180,6 +181,49 @@ function breadcrumbDropHandler(e) {
console.error("Invalid drag data on breadcrumb:", err);
return;
}
/* FOLDER MOVE FALLBACK */
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"); } 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;
}
const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []);
if (filesToMove.length === 0) return;
@@ -262,7 +306,7 @@ function renderFolderTree(tree, parentPath = "", defaultDisplay = "block") {
} else {
html += `<span class="folder-indent-placeholder"></span>`;
}
html += `<span class="folder-option" data-folder="${fullPath}">${escapeHTML(folder)}</span>`;
html += `<span class="folder-option" draggable="true" data-folder="${fullPath}">${escapeHTML(folder)}</span>`;
if (hasChildren) {
html += renderFolderTree(tree[folder], fullPath, displayState);
}
@@ -312,13 +356,58 @@ function folderDropHandler(event) {
event.preventDefault();
event.currentTarget.classList.remove("drop-hover");
const dropFolder = event.currentTarget.getAttribute("data-folder");
let dragData;
let dragData = null;
try {
dragData = JSON.parse(event.dataTransfer.getData("application/json"));
} catch (e) {
const jsonStr = event.dataTransfer.getData("application/json") || "";
if (jsonStr) dragData = JSON.parse(jsonStr);
}
catch (e) {
console.error("Invalid drag data", e);
return;
}
/* FOLDER MOVE FALLBACK */
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"); } 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;
}
const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []);
if (filesToMove.length === 0) return;
@@ -459,6 +548,14 @@ export async function loadFolderTree(selectedFolder) {
// Attach drag/drop event listeners.
container.querySelectorAll(".folder-option").forEach(el => {
// Provide folder path payload for folder->folder DnD
el.addEventListener("dragstart", (ev) => {
const src = el.getAttribute("data-folder");
try { ev.dataTransfer.setData("application/x-filerise-folder", src); } catch (e) {}
try { ev.dataTransfer.setData("text/plain", src); } catch (e) {}
ev.dataTransfer.effectAllowed = "move";
});
el.addEventListener("dragover", folderDragOverHandler);
el.addEventListener("dragleave", folderDragLeaveHandler);
el.addEventListener("drop", folderDropHandler);
@@ -487,6 +584,14 @@ export async function loadFolderTree(selectedFolder) {
// Folder-option click: update selection, breadcrumbs, and file list
container.querySelectorAll(".folder-option").forEach(el => {
// Provide folder path payload for folder->folder DnD
el.addEventListener("dragstart", (ev) => {
const src = el.getAttribute("data-folder");
try { ev.dataTransfer.setData("application/x-filerise-folder", src); } catch (e) {}
try { ev.dataTransfer.setData("text/plain", src); } catch (e) {}
ev.dataTransfer.effectAllowed = "move";
});
el.addEventListener("click", function (e) {
e.stopPropagation();
container.querySelectorAll(".folder-option").forEach(item => item.classList.remove("selected"));
@@ -642,6 +747,44 @@ if (submitRename) {
});
}
// === Move Folder Modal helper (shared by button + context menu) ===
function openMoveFolderUI(sourceFolder) {
const modal = document.getElementById('moveFolderModal');
const targetSel = document.getElementById('moveFolderTarget');
// If you right-clicked a different folder than currently selected, use that
if (sourceFolder && sourceFolder !== 'root') {
window.currentFolder = sourceFolder;
}
// Fill target dropdown
if (targetSel) {
targetSel.innerHTML = '';
fetch('/api/folder/getFolderList.php', { credentials: 'include' })
.then(r => r.json())
.then(list => {
if (Array.isArray(list) && list.length && typeof list[0] === 'object' && list[0].folder) {
list = list.map(it => it.folder);
}
// Root option
const rootOpt = document.createElement('option');
rootOpt.value = 'root'; rootOpt.textContent = '(Root)';
targetSel.appendChild(rootOpt);
(list || [])
.filter(f => f && f !== 'trash' && f !== (window.currentFolder || ''))
.forEach(f => {
const o = document.createElement('option');
o.value = f; o.textContent = f;
targetSel.appendChild(o);
});
})
.catch(()=>{ /* no-op */ });
}
if (modal) modal.style.display = 'block';
}
export function openDeleteFolderModal() {
const selectedFolder = window.currentFolder || "root";
if (!selectedFolder || selectedFolder === "root") {
@@ -841,6 +984,10 @@ function folderManagerContextMenuHandler(e) {
if (input) input.focus();
}
},
{
label: t("move_folder"),
action: () => { openMoveFolderUI(folder); }
},
{
label: t("rename_folder"),
action: () => { openRenameFolderModal(); }
@@ -923,4 +1070,53 @@ document.addEventListener("DOMContentLoaded", function () {
});
// Initial context menu delegation bind
bindFolderManagerContextMenu();
bindFolderManagerContextMenu();
document.addEventListener("DOMContentLoaded", () => {
const moveBtn = document.getElementById('moveFolderBtn');
const modal = document.getElementById('moveFolderModal');
const targetSel = document.getElementById('moveFolderTarget');
const cancelBtn = document.getElementById('cancelMoveFolder');
const confirmBtn= document.getElementById('confirmMoveFolder');
if (moveBtn) {
moveBtn.addEventListener('click', () => {
const cf = window.currentFolder || 'root';
if (!cf || cf === 'root') { showToast('Select a non-root folder to move.'); return; }
openMoveFolderUI(cf);
});
}
if (cancelBtn) cancelBtn.addEventListener('click', () => { if (modal) modal.style.display = 'none'; });
if (confirmBtn) confirmBtn.addEventListener('click', async () => {
if (!targetSel) return;
const destination = targetSel.value;
const source = window.currentFolder;
if (!destination) { showToast('Pick a destination'); return; }
if (destination === source || (destination + '/').startsWith(source + '/')) {
showToast('Invalid destination'); return;
}
try {
const res = await fetch('/api/folder/moveFolder.php', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken },
body: JSON.stringify({ source, destination })
});
const data = await safeJson(res);
if (res.ok && data && !data.error) {
showToast('Folder moved');
if (modal) modal.style.display='none';
await loadFolderTree();
const base = source.split('/').pop();
const newPath = (destination === 'root' ? '' : destination + '/') + base;
window.currentFolder = newPath;
loadFileList(window.currentFolder || 'root');
} else {
showToast('Error: ' + (data && data.error || 'Move failed'));
}
} catch (e) { console.error(e); showToast('Move failed'); }
});
});

View File

@@ -282,7 +282,27 @@ const translations = {
"bypass_ownership": "Bypass Ownership",
"error_loading_user_grants": "Error loading user grants",
"click_to_edit": "Click to edit",
"folder_access": "Folder Access"
"folder_access": "Folder Access",
"move_folder": "Move Folder",
"move_folder_message": "Select a destination folder to move this folder to:",
"move_folder_title": "Move this folder",
"move_folder_success": "Folder moved successfully.",
"move_folder_error": "Error moving folder.",
"move_folder_invalid": "Invalid source or destination folder.",
"move_folder_denied": "You do not have permission to move this folder.",
"move_folder_same_dest": "Destination cannot be the source or one of its subfolders.",
"move_folder_same_owner": "Source and destination must have the same owner.",
"move_folder_confirm": "Are you sure you want to move this folder?",
"move_folder_select_dest": "Select a destination folder",
"move_folder_select_dest_help": "Choose where this folder should be moved to.",
"acl_move_folder_label": "Move Folder (source)",
"acl_move_folder_help": "Allows moving this folder to a different parent. Requires Manage or Ownership on the folder.",
"acl_move_in_label": "Allow Moves Into This Folder (destination)",
"acl_move_in_help": "Allows items or folders from elsewhere to be moved into this folder. Requires Manage on the destination folder.",
"acl_move_folder_info": "Moving folders is restricted to folder owners or managers. Destination folders must also allow moves in.",
"context_move_folder": "Move Folder...",
"context_move_here": "Move Here",
"context_move_cancel": "Cancel Move"
},
es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",

View File

@@ -161,91 +161,91 @@ function createFileEntry(file) {
const removeBtn = document.createElement("button");
removeBtn.classList.add("remove-file-btn");
removeBtn.textContent = "×";
// In your remove button event listener, replace the fetch call with:
removeBtn.addEventListener("click", function (e) {
e.stopPropagation();
const uploadIndex = file.uploadIndex;
window.selectedFiles = window.selectedFiles.filter(f => f.uploadIndex !== uploadIndex);
// Cancel the file upload if possible.
if (typeof file.cancel === "function") {
file.cancel();
console.log("Canceled file upload:", file.fileName);
}
// Remove file from the resumable queue.
if (resumableInstance && typeof resumableInstance.removeFile === "function") {
resumableInstance.removeFile(file);
}
// Call our helper repeatedly to remove the chunk folder.
if (file.uniqueIdentifier) {
removeChunkFolderRepeatedly(file.uniqueIdentifier, window.csrfToken, 3, 1000);
}
li.remove();
updateFileInfoCount();
});
// In your remove button event listener, replace the fetch call with:
removeBtn.addEventListener("click", function (e) {
e.stopPropagation();
const uploadIndex = file.uploadIndex;
window.selectedFiles = window.selectedFiles.filter(f => f.uploadIndex !== uploadIndex);
// Cancel the file upload if possible.
if (typeof file.cancel === "function") {
file.cancel();
console.log("Canceled file upload:", file.fileName);
}
// Remove file from the resumable queue.
if (resumableInstance && typeof resumableInstance.removeFile === "function") {
resumableInstance.removeFile(file);
}
// Call our helper repeatedly to remove the chunk folder.
if (file.uniqueIdentifier) {
removeChunkFolderRepeatedly(file.uniqueIdentifier, window.csrfToken, 3, 1000);
}
li.remove();
updateFileInfoCount();
});
li.removeBtn = removeBtn;
li.appendChild(removeBtn);
// Add pause/resume/restart button if the file supports pause/resume.
// Conditionally add the pause/resume button only if file.pause is available
// Pause/Resume button (for resumable filepicker uploads)
if (typeof file.pause === "function") {
const pauseResumeBtn = document.createElement("button");
pauseResumeBtn.setAttribute("type", "button"); // not a submit button
pauseResumeBtn.classList.add("pause-resume-btn");
// Start with pause icon and disable button until upload starts
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
pauseResumeBtn.disabled = true;
pauseResumeBtn.addEventListener("click", function (e) {
e.stopPropagation();
if (file.isError) {
// If the file previously failed, try restarting upload.
if (typeof file.retry === "function") {
file.retry();
file.isError = false;
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
}
} else if (!file.paused) {
// Pause the upload (if possible)
if (typeof file.pause === "function") {
file.pause();
file.paused = true;
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">play_circle_outline</span>';
} else {
}
} else if (file.paused) {
// Resume sequence: first call to resume (or upload() fallback)
if (typeof file.resume === "function") {
file.resume();
} else {
resumableInstance.upload();
}
// After a short delay, pause again then resume
setTimeout(() => {
// Conditionally add the pause/resume button only if file.pause is available
// Pause/Resume button (for resumable filepicker uploads)
if (typeof file.pause === "function") {
const pauseResumeBtn = document.createElement("button");
pauseResumeBtn.setAttribute("type", "button"); // not a submit button
pauseResumeBtn.classList.add("pause-resume-btn");
// Start with pause icon and disable button until upload starts
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
pauseResumeBtn.disabled = true;
pauseResumeBtn.addEventListener("click", function (e) {
e.stopPropagation();
if (file.isError) {
// If the file previously failed, try restarting upload.
if (typeof file.retry === "function") {
file.retry();
file.isError = false;
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
}
} else if (!file.paused) {
// Pause the upload (if possible)
if (typeof file.pause === "function") {
file.pause();
file.paused = true;
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">play_circle_outline</span>';
} else {
}
} else if (file.paused) {
// Resume sequence: first call to resume (or upload() fallback)
if (typeof file.resume === "function") {
file.resume();
} else {
resumableInstance.upload();
}
// After a short delay, pause again then resume
setTimeout(() => {
if (typeof file.resume === "function") {
file.resume();
if (typeof file.pause === "function") {
file.pause();
} else {
resumableInstance.upload();
}
setTimeout(() => {
if (typeof file.resume === "function") {
file.resume();
} else {
resumableInstance.upload();
}
}, 100);
}, 100);
}, 100);
file.paused = false;
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
} else {
console.error("Pause/resume function not available for file", file);
}
});
li.appendChild(pauseResumeBtn);
}
file.paused = false;
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
} else {
console.error("Pause/resume function not available for file", file);
}
});
li.appendChild(pauseResumeBtn);
}
// Preview element
const preview = document.createElement("div");
@@ -406,20 +406,27 @@ let resumableInstance;
function initResumableUpload() {
resumableInstance = new Resumable({
target: "/api/upload/upload.php",
query: { folder: window.currentFolder || "root", upload_token: window.csrfToken },
chunkSize: 1.5 * 1024 * 1024, // 1.5 MB chunks
chunkSize: 1.5 * 1024 * 1024,
simultaneousUploads: 3,
forceChunkSize: true,
testChunks: false,
throttleProgressCallbacks: 1,
withCredentials: true,
headers: { 'X-CSRF-Token': window.csrfToken },
query: {
query: () => ({
folder: window.currentFolder || "root",
upload_token: window.csrfToken // still as a fallback
}
upload_token: window.csrfToken
})
});
// keep query fresh when folder changes (call this from your folder nav code)
function updateResumableQuery() {
if (!resumableInstance) return;
resumableInstance.opts.headers['X-CSRF-Token'] = window.csrfToken;
// if you're not using a function for query, do:
resumableInstance.opts.query.folder = window.currentFolder || 'root';
resumableInstance.opts.query.upload_token = window.csrfToken;
}
const fileInput = document.getElementById("file");
if (fileInput) {
// Assign Resumable to file input for file picker uploads.
@@ -432,6 +439,7 @@ function initResumableUpload() {
}
resumableInstance.on("fileAdded", function (file) {
// Initialize custom paused flag
file.paused = false;
file.uploadIndex = file.uniqueIdentifier;
@@ -461,16 +469,17 @@ function initResumableUpload() {
li.dataset.uploadIndex = file.uniqueIdentifier;
list.appendChild(li);
updateFileInfoCount();
updateResumableQuery();
});
resumableInstance.on("fileProgress", function(file) {
resumableInstance.on("fileProgress", function (file) {
const progress = file.progress(); // value between 0 and 1
const percent = Math.floor(progress * 100);
const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`);
if (li && li.progressBar) {
if (percent < 99) {
li.progressBar.style.width = percent + "%";
// Calculate elapsed time and speed.
const elapsed = (Date.now() - li.startTime) / 1000;
let speed = "";
@@ -491,7 +500,7 @@ function initResumableUpload() {
li.progressBar.style.width = "100%";
li.progressBar.innerHTML = '<i class="material-icons spinning" style="vertical-align: middle;">autorenew</i>';
}
// Enable the pause/resume button once progress starts.
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
if (pauseResumeBtn) {
@@ -499,8 +508,8 @@ function initResumableUpload() {
}
}
});
resumableInstance.on("fileSuccess", function(file, message) {
resumableInstance.on("fileSuccess", function (file, message) {
// Try to parse JSON response
let data;
try {
@@ -508,18 +517,18 @@ function initResumableUpload() {
} catch (e) {
data = null;
}
// 1) Softfail CSRF? then update token & retry this file
if (data && data.csrf_expired) {
// Update global and Resumable headers
window.csrfToken = data.csrf_token;
resumableInstance.opts.headers['X-CSRF-Token'] = data.csrf_token;
resumableInstance.opts.query.upload_token = data.csrf_token;
resumableInstance.opts.query.upload_token = data.csrf_token;
// Retry this chunk/file
file.retry();
return;
return;
}
// 2) Otherwise treat as real success:
const li = document.querySelector(
`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`
@@ -531,13 +540,13 @@ function initResumableUpload() {
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
if (pauseResumeBtn) pauseResumeBtn.style.display = "none";
const removeBtn = li.querySelector(".remove-file-btn");
if (removeBtn) removeBtn.style.display = "none";
if (removeBtn) removeBtn.style.display = "none";
setTimeout(() => li.remove(), 5000);
}
loadFileList(window.currentFolder);
});
resumableInstance.on("fileError", function (file, message) {
@@ -637,7 +646,7 @@ function submitFiles(allFiles) {
} catch (e) {
jsonResponse = null;
}
// ─── Soft-fail CSRF: retry this upload ───────────────────────
if (jsonResponse && jsonResponse.csrf_expired) {
console.warn("CSRF expired during upload, retrying chunk", file.uploadIndex);
@@ -650,10 +659,10 @@ function submitFiles(allFiles) {
xhr.send(formData);
return; // skip the "finishedCount++" and error/success logic for now
}
// ─── Normal success/error handling ────────────────────────────
const li = progressElements[file.uploadIndex];
if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) {
// real success
if (li) {
@@ -662,6 +671,7 @@ function submitFiles(allFiles) {
if (li.removeBtn) li.removeBtn.style.display = "none";
}
uploadResults[file.uploadIndex] = true;
} else {
// real failure
if (li) {
@@ -681,12 +691,17 @@ function submitFiles(allFiles) {
}
}, 5000);
}
// ─── Only now count this chunk as finished ───────────────────
finishedCount++;
if (finishedCount === allFiles.length) {
refreshFileList(allFiles, uploadResults, progressElements);
}
if (finishedCount === allFiles.length) {
const succeededCount = uploadResults.filter(Boolean).length;
const failedCount = allFiles.length - succeededCount;
setTimeout(() => {
refreshFileList(allFiles, uploadResults, progressElements);
}, 250);
}
});
xhr.addEventListener("error", function () {
@@ -699,6 +714,9 @@ function submitFiles(allFiles) {
finishedCount++;
if (finishedCount === allFiles.length) {
refreshFileList(allFiles, uploadResults, progressElements);
// Immediate summary toast based on actual XHR outcomes
const succeededCount = uploadResults.filter(Boolean).length;
const failedCount = allFiles.length - succeededCount;
}
});
@@ -725,17 +743,30 @@ function submitFiles(allFiles) {
loadFileList(folderToUse)
.then(serverFiles => {
initFileActions();
serverFiles = (serverFiles || []).map(item => item.name.trim().toLowerCase());
// Be tolerant to API shapes: string or object with name/fileName/filename
serverFiles = (serverFiles || [])
.map(item => {
if (typeof item === 'string') return item;
const n = item?.name ?? item?.fileName ?? item?.filename ?? '';
return String(n);
})
.map(s => s.trim().toLowerCase())
.filter(Boolean);
let overallSuccess = true;
let succeeded = 0;
allFiles.forEach(file => {
const clientFileName = file.name.trim().toLowerCase();
const li = progressElements[file.uploadIndex];
if (!uploadResults[file.uploadIndex] || !serverFiles.includes(clientFileName)) {
const hadRelative = !!(file.webkitRelativePath || file.customRelativePath);
if (!uploadResults[file.uploadIndex] || (!hadRelative && !serverFiles.includes(clientFileName))) {
if (li) {
li.progressBar.innerText = "Error";
}
overallSuccess = false;
} else if (li) {
succeeded++;
// Schedule removal of successful file entry after 5 seconds.
setTimeout(() => {
li.remove();
@@ -757,9 +788,12 @@ function submitFiles(allFiles) {
}, 5000);
}
});
if (!overallSuccess) {
showToast("Some files failed to upload. Please check the list.");
const failed = allFiles.length - succeeded;
showToast(`${failed} file(s) failed, ${succeeded} succeeded. Please check the list.`);
} else {
showToast(`${succeeded} file succeeded. Please check the list.`);
}
})
.catch(error => {
@@ -768,6 +802,7 @@ function submitFiles(allFiles) {
})
.finally(() => {
loadFolderTree(window.currentFolder);
});
}
}

View File

@@ -501,7 +501,7 @@ public function deleteFiles()
$userPermissions = $this->loadPerms($username);
// Need granular rename (or ancestor-owner)
if (!(ACL::canRename($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
if (!(ACL::canRename($username, $userPermissions, $folder))) {
$this->_jsonOut(["error"=>"Forbidden: no rename rights"], 403); return;
}

View File

@@ -695,4 +695,79 @@ for ($i = $startPage; $i <= $endPage; $i++): ?>
echo json_encode(['success' => false, 'error' => 'Not found']);
}
}
}
/* -------------------- 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; }
// 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; }
$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 ($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 ($parts as $seg) {
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, "/\\ ");
// 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;
}
$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; }
// 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; }
// 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) ?? '');
if ($ownerSrc !== $ownerDst) {
http_response_code(403); echo json_encode(['error'=>'Source and destination must have the same owner']); return;
}
} catch (\Throwable $e) { /* ignore fall through */ }
}
// Compute final target "destination/basename(source)"
$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);
} catch (\Throwable $e) {
error_log('moveFolder error: '.$e->getMessage());
http_response_code(500);
echo json_encode(['error'=>'Internal error moving folder']);
}
}
}

View File

@@ -40,6 +40,48 @@ class ACL
unset($rec);
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 = 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;
}
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
$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;
}
}
$acl['folders'] = $rebased;
self::save($acl);
}
private static function loadFresh(): array {
$path = self::path();
@@ -323,10 +365,10 @@ class ACL
$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 = $mv = $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 = $mv = $dl = $ex = true; }
if ($w) { $c = $u = $ed = $rn = $cp = $dl = $ex = true; }
if ($m) $rec['owners'][] = $user;
if ($v) $rec['read'][] = $user;
@@ -419,9 +461,13 @@ public static function canCopy(string $user, array $perms, string $folder): bool
public static function canMove(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, 'move')
|| self::hasGrant($user, $folder, 'write');
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 {

View File

@@ -326,6 +326,8 @@ class FolderModel
// Update ownership mapping for the entire subtree.
self::renameOwnersForTree($oldRel, $newRel);
// Re-key explicit ACLs for the moved subtree
ACL::renameTree($oldRel, $newRel);
return ["success" => true];
}

View File

@@ -4,6 +4,19 @@
require_once PROJECT_ROOT . '/config/config.php';
class UploadModel {
private static function sanitizeFolder(string $folder): string {
$folder = trim($folder);
if ($folder === '' || strtolower($folder) === 'root') return '';
// no traversal
if (strpos($folder, '..') !== false) return '';
// only safe chars + forward slashes
if (!preg_match('/^[A-Za-z0-9_\-\/]+$/', $folder)) return '';
// normalize: strip leading slashes
return ltrim($folder, '/');
}
/**
* Handles file uploads supports both chunked uploads and full (non-chunked) uploads.
*
@@ -38,15 +51,19 @@ class UploadModel {
return ["error" => "Invalid file name: $resumableFilename"];
}
$folder = isset($post['folder']) ? trim($post['folder']) : 'root';
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
return ["error" => "Invalid folder name"];
}
$folderRaw = $post['folder'] ?? 'root';
$folderSan = self::sanitizeFolder((string)$folderRaw);
if (empty($files['file']) || !isset($files['file']['name'])) {
return ["error" => "No files received"];
}
$baseUploadDir = UPLOAD_DIR;
if ($folder !== 'root') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder . DIRECTORY_SEPARATOR;
}
if ($folderSan !== '') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
}
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
return ["error" => "Failed to create upload directory"];
}
@@ -56,12 +73,14 @@ class UploadModel {
return ["error" => "Failed to create temporary chunk directory"];
}
if (!isset($files["file"]) || $files["file"]["error"] !== UPLOAD_ERR_OK) {
$chunkErr = $files['file']['error'] ?? UPLOAD_ERR_NO_FILE;
if ($chunkErr !== UPLOAD_ERR_OK) {
return ["error" => "Upload error on chunk $chunkNumber"];
}
$chunkFile = $tempDir . $chunkNumber;
if (!move_uploaded_file($files["file"]["tmp_name"], $chunkFile)) {
$tmpName = $files['file']['tmp_name'] ?? null;
if (!$tmpName || !move_uploaded_file($tmpName, $chunkFile)) {
return ["error" => "Failed to move uploaded chunk $chunkNumber"];
}
@@ -100,8 +119,7 @@ class UploadModel {
fclose($out);
// Update metadata.
$relativeFolder = $folder;
$metadataKey = ($relativeFolder === '' || strtolower($relativeFolder) === 'root') ? "root" : $relativeFolder;
$metadataKey = ($folderSan === '') ? "root" : $folderSan;
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName;
$uploadedDate = date(DATE_TIME_FORMAT);
@@ -134,16 +152,16 @@ class UploadModel {
return ["success" => "File uploaded successfully"];
} else {
// Handle full upload (non-chunked).
$folder = isset($post['folder']) ? trim($post['folder']) : 'root';
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
return ["error" => "Invalid folder name"];
// Handle full upload (non-chunked)
$folderRaw = $post['folder'] ?? 'root';
$folderSan = self::sanitizeFolder((string)$folderRaw);
}
$baseUploadDir = UPLOAD_DIR;
if ($folder !== 'root') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder . DIRECTORY_SEPARATOR;
}
if ($folderSan !== '') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
}
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
return ["error" => "Failed to create upload directory"];
}
@@ -153,6 +171,10 @@ class UploadModel {
$metadataChanged = [];
foreach ($files["file"]["name"] as $index => $fileName) {
// Basic PHP upload error check per file
if (($files['file']['error'][$index] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
return ["error" => "Error uploading file"];
}
$safeFileName = trim(urldecode(basename($fileName)));
if (!preg_match($safeFileNamePattern, $safeFileName)) {
return ["error" => "Invalid file name: " . $fileName];
@@ -161,21 +183,22 @@ class UploadModel {
if (isset($post['relativePath'])) {
$relativePath = is_array($post['relativePath']) ? $post['relativePath'][$index] ?? '' : $post['relativePath'];
}
$uploadDir = $baseUploadDir;
$uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR;
if (!empty($relativePath)) {
$subDir = dirname($relativePath);
if ($subDir !== '.' && $subDir !== '') {
$uploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $subDir) . DIRECTORY_SEPARATOR;
// IMPORTANT: build the subfolder under the *current* base folder
$uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR .
str_replace('/', DIRECTORY_SEPARATOR, $subDir) . DIRECTORY_SEPARATOR;
}
$safeFileName = basename($relativePath);
}
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0775, true)) {
return ["error" => "Failed to create subfolder"];
if (!is_dir($uploadDir) && !@mkdir($uploadDir, 0775, true)) {
return ["error" => "Failed to create subfolder: " . $uploadDir];
}
$targetPath = $uploadDir . $safeFileName;
if (move_uploaded_file($files["file"]["tmp_name"][$index], $targetPath)) {
$folderPath = $folder;
$metadataKey = ($folderPath === '' || strtolower($folderPath) === 'root') ? "root" : $folderPath;
$metadataKey = ($folderSan === '') ? "root" : $folderSan;
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName;
if (!isset($metadataCollection[$metadataKey])) {
@@ -208,7 +231,7 @@ class UploadModel {
}
return ["success" => "Files uploaded successfully"];
}
}
/**
* Recursively removes a directory and its contents.