release(v1.9.4): lazy folder tree, cursor pagination, ACL-safe chevrons, and “Load more”

- Lazy folder tree via /api/folder/listChildren.php with cursor pagination
- ACL-safe chevrons using hasSubfolders from server; no file-count leaks
- BFS smart initial folder selection + respect lastOpenedFolder
- Locked nodes are expandable but not selectable
- “Load more” UX (light & dark) for huge directories

Closes #66
This commit is contained in:
Ryan
2025-11-13 05:06:24 -05:00
committed by GitHub
parent f1dcc0df24
commit f5e42a2e81
12 changed files with 2184 additions and 1521 deletions

View File

@@ -1,5 +1,61 @@
# Changelog
## Changes 11/13/2025 (v1.9.4)
release(v1.9.4): lazy folder tree, cursor pagination, ACL-safe chevrons, and “Load more” (closes #66)
**Big focus on folder management performance & UX for large libraries.**
feat(folder-tree):
- Lazy-load children on demand with cursor-based pagination (`nextCursor` + `limit`), including inline “Load more” row.
- BFS-based initial selection: if user cant view requested/default folder, auto-pick the first accessible folder (but stick to (Root) when user can view it).
- Persisted expansion state across reloads; restore saved path and last opened folder; prevent navigation into locked folders (shows i18n toast instead).
- Breadcrumb now respects ACL: clicking a locked crumb toggles expansion only (no navigation).
- Live chevrons from server truth: `hasSubfolders` is computed server-side to avoid file count probes and show correct expanders (even when a direct child is unreadable).
- Capabilities-driven toolbar enable/disable for create/move/rename/color/delete/share.
- Color-carry on move/rename + expansion state migration so moved/renamed nodes keep colors and stay visible.
- Root DnD honored only when viewable; structural locks disable dragging.
perf(core):
- New `FS.php` helpers: safe path resolution (`safeReal`), segment sanitization, symlink defense, ignore/skip lists, bounded child counting, `hasSubfolders`, and `hasReadableDescendant` (depth-limited).
- Thin caching for child lists and counts, with targeted cache invalidation on move/rename/create/delete.
- Bounded concurrency for folder count requests; short timeouts to keep UI snappy.
api/model:
- `FolderModel::listChildren(...)` now returns items shaped like:
`{ name, locked, hasSubfolders, nonEmpty? }`
- `nonEmpty` included only for unlocked nodes (prevents side-channel leakage).
- Locked nodes are only returned when `hasReadableDescendant(...)` is true (preserves legacy “structural visibility without listing the entire tree” behavior).
- `public/api/folder/listChildren.php` delegates to controller/model; `isEmpty.php` hardened; `capabilities.php` exposes `canView` (or derived) for fast checks.
- Folder color endpoints gate results by ACL so users only see colors for folders they can at least “own-view”.
ui/ux:
- New “Load more” row (`<li class="load-more">`) with dark-mode friendly ghost button styling; consistent padding, focus ring, hover state.
- Locked folders render with padlock overlay and no DnD; improved contrast/spacing; icons/chevrons update live as children load.
- i18n additions: `no_access`, `load_more`, `color_folder(_saved|_cleared)`, `please_select_valid_folder`, etc.
- When a user has zero access anywhere, tree selects (Root) but shows `no_access` instead of “No files found”.
security:
- Stronger path traversal + symlink protections across folder APIs (all joins normalized, base-anchored).
- Reduced metadata leakage by omitting `nonEmpty` for locked nodes and depth-limiting descendant checks.
fixes:
- Chevron visibility for unreadable intermediate nodes (e.g., “Files” shows a chevron when it contains a readable “Resources” descendant).
- Refresh now honors the actively viewed folder (session/localStorage), not the first globally readable folder.
chore:
- CSS additions for locked state, tree rows, and dark-mode ghost buttons.
- Minor code cleanups and comments across controller/model and JS tree logic.
---
## Changes 11/11/2025 (v1.9.3)
release(v1.9.3): unify folder icons across tree & strip, add “paper” lines, live color sync, and vendor-aware release

View File

@@ -1,245 +1,18 @@
<?php
// public/api/folder/capabilities.php
/**
* @OA\Get(
* path="/api/folder/capabilities.php",
* summary="Get effective capabilities for the current user in a folder",
* description="Computes the caller's capabilities for a given folder by combining account flags (readOnly/disableUpload), ACL grants (read/write/share), and the user-folder-only scope. Returns booleans indicating what the user can do.",
* operationId="getFolderCapabilities",
* tags={"Folders"},
* security={{"cookieAuth": {}}},
*
* @OA\Parameter(
* name="folder",
* in="query",
* required=false,
* description="Target folder path. Defaults to 'root'. Supports nested paths like 'team/reports'.",
* @OA\Schema(type="string"),
* example="projects/acme"
* ),
*
* @OA\Response(
* response=200,
* description="Capabilities computed successfully.",
* @OA\JsonContent(
* type="object",
* required={"user","folder","isAdmin","flags","canView","canUpload","canCreate","canRename","canDelete","canMoveIn","canShare"},
* @OA\Property(property="user", type="string", example="alice"),
* @OA\Property(property="folder", type="string", example="projects/acme"),
* @OA\Property(property="isAdmin", type="boolean", example=false),
* @OA\Property(
* property="flags",
* type="object",
* required={"folderOnly","readOnly","disableUpload"},
* @OA\Property(property="folderOnly", type="boolean", example=false),
* @OA\Property(property="readOnly", type="boolean", example=false),
* @OA\Property(property="disableUpload", type="boolean", example=false)
* ),
* @OA\Property(property="owner", type="string", nullable=true, example="alice"),
* @OA\Property(property="canView", type="boolean", example=true, description="User can view items in this folder."),
* @OA\Property(property="canUpload", type="boolean", example=true, description="User can upload/edit/rename/move/delete items (i.e., WRITE)."),
* @OA\Property(property="canCreate", type="boolean", example=true, description="User can create subfolders here."),
* @OA\Property(property="canRename", type="boolean", example=true, description="User can rename items here."),
* @OA\Property(property="canDelete", type="boolean", example=true, description="User can delete items here."),
* @OA\Property(property="canMoveIn", type="boolean", example=true, description="User can move items into this folder."),
* @OA\Property(property="canShare", type="boolean", example=false, description="User can create share links for this folder.")
* )
* ),
* @OA\Response(response=400, description="Invalid folder name."),
* @OA\Response(response=401, ref="#/components/responses/Unauthorized")
* )
*/
declare(strict_types=1);
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
require_once PROJECT_ROOT . '/src/models/UserModel.php';
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
header('Content-Type: application/json');
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
$username = (string)($_SESSION['username'] ?? '');
if ($username === '') { http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; }
@session_write_close();
// --- auth ---
$username = $_SESSION['username'] ?? '';
if ($username === '') {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
$folder = str_replace('\\', '/', trim($folder));
$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');
// --- helpers ---
function loadPermsFor(string $u): array {
try {
if (function_exists('loadUserPermissions')) {
$p = loadUserPermissions($u);
return is_array($p) ? $p : [];
}
if (class_exists('userModel') && method_exists('userModel', 'getUserPermissions')) {
$all = userModel::getUserPermissions();
if (is_array($all)) {
if (isset($all[$u])) return (array)$all[$u];
$lk = strtolower($u);
if (isset($all[$lk])) return (array)$all[$lk];
}
}
} catch (Throwable $e) {}
return [];
}
function isOwnerOrAncestorOwner(string $user, array $perms, string $folder): bool {
$f = ACL::normalizeFolder($folder);
// direct owner
if (ACL::isOwner($user, $perms, $f)) return true;
// ancestor owner
while ($f !== '' && strcasecmp($f, 'root') !== 0) {
$pos = strrpos($f, '/');
if ($pos === false) break;
$f = substr($f, 0, $pos);
if ($f === '' || strcasecmp($f, 'root') === 0) break;
if (ACL::isOwner($user, $perms, $f)) return true;
}
return false;
}
/**
* folder-only scope:
* - Admins: always in scope
* - Non folder-only accounts: always in scope
* - Folder-only accounts: in scope iff:
* - folder == username OR subpath of username, OR
* - user is owner of this folder (or any ancestor)
*/
function inUserFolderScope(string $folder, string $u, array $perms, bool $isAdmin): bool {
if ($isAdmin) return true;
//$folderOnly = !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']);
//if (!$folderOnly) return true;
$f = ACL::normalizeFolder($folder);
if ($f === 'root' || $f === '') {
// folder-only users cannot act on root unless they own a subfolder (handled below)
return isOwnerOrAncestorOwner($u, $perms, $f);
}
if ($f === $u || str_starts_with($f, $u . '/')) return true;
// Treat ownership as in-scope
return isOwnerOrAncestorOwner($u, $perms, $f);
}
// --- inputs ---
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
// validate folder path
if ($folder !== 'root') {
$parts = array_filter(explode('/', trim($folder, "/\\ ")));
if (empty($parts)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid folder name.']);
exit;
}
foreach ($parts as $seg) {
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid folder name.']);
exit;
}
}
$folder = implode('/', $parts);
}
// --- user + flags ---
$perms = loadPermsFor($username);
$isAdmin = ACL::isAdmin($perms);
$readOnly = !empty($perms['readOnly']);
$inScope = inUserFolderScope($folder, $username, $perms, $isAdmin);
// --- ACL base abilities ---
$canViewBase = $isAdmin || ACL::canRead($username, $perms, $folder);
$canViewOwn = $isAdmin || ACL::canReadOwn($username, $perms, $folder);
$canWriteBase = $isAdmin || ACL::canWrite($username, $perms, $folder);
$canShareBase = $isAdmin || ACL::canShare($username, $perms, $folder);
$canManageBase = $isAdmin || ACL::canManage($username, $perms, $folder);
// granular base
$gCreateBase = $isAdmin || ACL::canCreate($username, $perms, $folder);
$gRenameBase = $isAdmin || ACL::canRename($username, $perms, $folder);
$gDeleteBase = $isAdmin || ACL::canDelete($username, $perms, $folder);
$gMoveBase = $isAdmin || ACL::canMove($username, $perms, $folder);
$gUploadBase = $isAdmin || ACL::canUpload($username, $perms, $folder);
$gEditBase = $isAdmin || ACL::canEdit($username, $perms, $folder);
$gCopyBase = $isAdmin || ACL::canCopy($username, $perms, $folder);
$gExtractBase = $isAdmin || ACL::canExtract($username, $perms, $folder);
$gShareFile = $isAdmin || ACL::canShareFile($username, $perms, $folder);
$gShareFolder = $isAdmin || ACL::canShareFolder($username, $perms, $folder);
// --- Apply scope + flags to effective UI actions ---
$canView = $canViewBase && $inScope; // keep scope for folder-only
$canUpload = $gUploadBase && !$readOnly && $inScope;
$canCreate = $canManageBase && !$readOnly && $inScope; // Create **folder**
$canRename = $canManageBase && !$readOnly && $inScope; // Rename **folder**
$canDelete = $gDeleteBase && !$readOnly && $inScope;
// Destination can receive items if user can create/write (or manage) here
$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;
// Sharing respects scope; optionally also gate on readOnly
$canShare = $canShareBase && $inScope; // legacy umbrella
$canShareFileEff = $gShareFile && $inScope;
$canShareFoldEff = $gShareFolder && $inScope;
// never allow destructive ops on root
$isRoot = ($folder === 'root');
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;
try { $owner = FolderModel::getOwnerFor($folder); } catch (Throwable $e) {}
echo json_encode([
'user' => $username,
'folder' => $folder,
'isAdmin' => $isAdmin,
'flags' => [
//'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']),
'readOnly' => $readOnly,
],
'owner' => $owner,
// viewing
'canView' => $canView,
'canViewOwn' => $canViewOwn,
// write-ish
'canUpload' => $canUpload,
'canCreate' => $canCreate,
'canRename' => $canRename,
'canDelete' => $canDelete,
'canMoveIn' => $canMoveIn,
'canMove' => $canMoveAlias,
'canMoveFolder'=> $canMoveFolder,
'canEdit' => $canEdit,
'canCopy' => $canCopy,
'canExtract' => $canExtract,
// sharing
'canShare' => $canShare, // legacy
'canShareFile' => $canShareFileEff,
'canShareFolder' => $canShareFoldEff,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
echo json_encode(FolderController::capabilities($folder, $username), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

View File

@@ -1,30 +1,28 @@
<?php
// public/api/folder/isEmpty.php
// Fast ACL-aware peek for tree icons/chevrons
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
// Snapshot then release session lock so parallel requests dont block
$user = (string)($_SESSION['username'] ?? '');
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
if (empty($_SESSION['authenticated'])) { http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; }
$username = (string)($_SESSION['username'] ?? '');
$perms = [
'role' => $_SESSION['role'] ?? null,
'admin' => $_SESSION['admin'] ?? null,
'isAdmin' => $_SESSION['isAdmin'] ?? null,
'role' => $_SESSION['role'] ?? null,
'admin' => $_SESSION['admin'] ?? null,
'isAdmin' => $_SESSION['isAdmin'] ?? null,
'folderOnly' => $_SESSION['folderOnly'] ?? null,
'readOnly' => $_SESSION['readOnly'] ?? null,
];
@session_write_close();
// Input
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
$folder = str_replace('\\', '/', trim($folder));
$folder = ($folder === '' || $folder === 'root') ? 'root' : trim($folder, '/');
$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');
// Delegate to controller (model handles ACL + path safety)
$result = FolderController::stats($folder, $user, $perms);
// Always return a compact JSON object like before
echo json_encode([
'folders' => (int)($result['folders'] ?? 0),
'files' => (int)($result['files'] ?? 0),
]);
echo json_encode(FolderController::stats($folder, $username, $perms), JSON_UNESCAPED_SLASHES);

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
if (empty($_SESSION['authenticated'])) { http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; }
$username = (string)($_SESSION['username'] ?? '');
$perms = [
'role' => $_SESSION['role'] ?? null,
'admin' => $_SESSION['admin'] ?? null,
'isAdmin' => $_SESSION['isAdmin'] ?? null,
'folderOnly' => $_SESSION['folderOnly'] ?? null,
'readOnly' => $_SESSION['readOnly'] ?? null,
];
@session_write_close();
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
$folder = str_replace('\\', '/', trim($folder));
$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');
$limit = max(1, min(2000, (int)($_GET['limit'] ?? 500)));
$cursor = isset($_GET['cursor']) && $_GET['cursor'] !== '' ? (string)$_GET['cursor'] : null;
$res = FolderController::listChildren($folder, $username, $perms, $cursor, $limit);
echo json_encode($res, JSON_UNESCAPED_SLASHES);

View File

@@ -2413,3 +2413,67 @@ body.dark-mode .folder-strip-container .folder-item:hover {
.folder-strip-container .folder-svg .paper-line,
.folder-strip-container .folder-svg .paper-ink,
.folder-strip-container .folder-svg .lip-highlight { stroke-width: 1.1px; }
/* Locked folder look (keep subtle but clear) */
#folderTreeContainer .folder-option.locked {
opacity: .9;
font-style: italic;
}
/* Padlock styling inside the SVG */
#folderTreeContainer .folder-icon .lock-body {
fill: var(--filr-folder-stroke, #6b6b6b);
opacity: .95;
}
#folderTreeContainer .folder-icon .lock-shackle {
fill: none;
stroke: var(--filr-folder-stroke, #6b6b6b);
stroke-width: 1.4;
stroke-linecap: round;
}
#folderTreeContainer .folder-icon .lock-keyhole {
fill: rgba(0,0,0,.28);
}
body.dark-mode #folderTreeContainer .folder-icon .lock-keyhole {
fill: rgba(255,255,255,.28);
}
#folderTreeContainer li.load-more {
padding: 4px 0 4px 28px; /* indent to line up with rows */
list-style: none;
}
#folderTreeContainer li.load-more > .btn.btn-ghost {
width: calc(100% - 8px);
margin: 0 4px;
display: flex;
align-items: center;
gap: 8px;
justify-content: center;
border-radius: 10px;
border: 1px solid var(--tree-ghost-border);
background: var(--tree-ghost-bg);
color: var(--tree-ghost-fg);
padding: 6px 10px;
font-size: 12.5px;
}
#folderTreeContainer li.load-more > .btn.btn-ghost:hover {
background: var(--tree-ghost-hover-bg);
}
#folderTreeContainer li.load-more > .btn.btn-ghost:focus-visible {
outline: 2px solid #8ab4f8;
outline-offset: 2px;
}
/* tiny spinner when busy */
#folderTreeContainer li.load-more > .btn[aria-busy="true"]::before {
content: "";
width: 12px; height: 12px;
border-radius: 50%;
border: 2px solid currentColor;
border-right-color: transparent;
display: inline-block;
animation: filr-spin .8s linear infinite;
}
@keyframes filr-spin { to { transform: rotate(360deg); } }

View File

@@ -277,14 +277,24 @@
</div>
</div>
<div id="folderHelpTooltip" class="folder-help-tooltip"
style="display: none; position: absolute; top: 50px; right: 15px; background: #fff; border: 1px solid #ccc; padding: 10px; z-index: 1000; box-shadow: 2px 2px 6px rgba(0,0,0,0.2);">
<ul class="folder-help-list" style="margin: 0; padding-left: 20px;">
<li data-i18n-key="folder_help_item_1">Click on a folder in the tree to view its files.</li>
<li data-i18n-key="folder_help_item_2">Use [-] to collapse and [+] to expand folders.</li>
<li data-i18n-key="folder_help_item_3">Select a folder and click "Create Folder" to add a
subfolder.</li>
<li data-i18n-key="folder_help_item_4">To rename or delete a folder, select it and then click
the appropriate button.</li>
style="display:none;position:absolute;top:50px;right:15px;background:#fff;border:1px solid #ccc;padding:10px;z-index:1000;box-shadow:2px 2px 6px rgba(0,0,0,0.2);border-radius:8px;max-width:320px;line-height:1.35;">
<style>
/* Dark mode polish */
body.dark-mode #folderHelpTooltip {
background:#2c2c2c; border-color:#555; color:#e8e8e8; box-shadow:2px 2px 10px rgba(0,0,0,.5);
}
#folderHelpTooltip .folder-help-list { margin:0; padding-left:18px; }
#folderHelpTooltip .folder-help-list li { margin:6px 0; }
</style>
<ul class="folder-help-list">
<li data-i18n-key="folder_help_click_view">Click a folder in the tree to view its files.</li>
<li data-i18n-key="folder_help_expand_chevrons">Use chevrons to expand/collapse. Locked folders (padlock) can expand but cant be opened.</li>
<li data-i18n-key="folder_help_context_menu">Right-click a folder for quick actions: Create, Move, Rename, Share, Color, Delete.</li>
<li data-i18n-key="folder_help_drag_drop">Drag a folder onto another folder <em>or</em> a breadcrumb to move it.</li>
<li data-i18n-key="folder_help_load_more">For long lists, click “Load more” to fetch the next page of folders.</li>
<li data-i18n-key="folder_help_last_folder">Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.</li>
<li data-i18n-key="folder_help_breadcrumbs">Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.</li>
<li data-i18n-key="folder_help_permissions">Buttons enable/disable based on your permissions for the selected folder.</li>
</ul>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -318,7 +318,19 @@ const translations = {
"reset_default": "Reset",
"save_color": "Save",
"folder_color_saved": "Folder color saved.",
"folder_color_cleared": "Folder color reset."
"folder_color_cleared": "Folder color reset.",
"load_more": "Load more",
"loading": "Loading...",
"no_access": "You do not have access to this resource.",
"please_select_valid_folder": "Please select a valid folder.",
"folder_help_click_view": "Click a folder in the tree to view its files.",
"folder_help_expand_chevrons": "Use chevrons to expand/collapse. Locked folders (padlock) can expand but cant be opened.",
"folder_help_context_menu": "Right-click a folder for quick actions: Create, Move, Rename, Share, Color, Delete.",
"folder_help_drag_drop": "Drag a folder onto another folder or a breadcrumb to move it.",
"folder_help_load_more": "For long lists, click “Load more” to fetch the next page of folders.",
"folder_help_last_folder": "Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.",
"folder_help_breadcrumbs": "Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.",
"folder_help_permissions": "Buttons enable/disable based on your permissions for the selected folder."
},
es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",

View File

@@ -6,6 +6,7 @@ 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';
require_once PROJECT_ROOT . '/src/lib/FS.php';
class FolderController
{
@@ -31,6 +32,10 @@ class FolderController
return $headers;
}
public static function listChildren(string $folder, string $user, array $perms, ?string $cursor = null, int $limit = 500): array {
return FolderModel::listChildren($folder, $user, $perms, $cursor, $limit);
}
/** Stats for a folder (currently: empty/non-empty via folders/files counts). */
public static function stats(string $folder, string $user, array $perms): array
{
@@ -38,6 +43,161 @@ class FolderController
return FolderModel::countVisible($folder, $user, $perms);
}
/** Capabilities for UI buttons/menus (unchanged semantics; just centralized). */
public static function capabilities(string $folder, string $username): array
{
$folder = ACL::normalizeFolder($folder);
$perms = self::loadPermsFor($username);
$isAdmin = ACL::isAdmin($perms);
$folderOnly = self::boolFrom($perms, 'folderOnly','userFolderOnly','UserFolderOnly');
$readOnly = !empty($perms['readOnly']);
$disableUpload = !empty($perms['disableUpload']);
$isOwner = ACL::isOwner($username, $perms, $folder);
$inScope = self::inUserFolderScope($folder, $username, $perms, $isAdmin, $folderOnly);
$canViewBase = $isAdmin || ACL::canRead($username, $perms, $folder);
$canViewOwn = $isAdmin || ACL::canReadOwn($username, $perms, $folder);
$canShareBase = $isAdmin || ACL::canShare($username, $perms, $folder);
$gCreateBase = $isAdmin || ACL::canCreate($username, $perms, $folder);
$gRenameBase = $isAdmin || ACL::canRename($username, $perms, $folder);
$gDeleteBase = $isAdmin || ACL::canDelete($username, $perms, $folder);
$gMoveBase = $isAdmin || ACL::canMove($username, $perms, $folder);
$gUploadBase = $isAdmin || ACL::canUpload($username, $perms, $folder);
$gEditBase = $isAdmin || ACL::canEdit($username, $perms, $folder);
$gCopyBase = $isAdmin || ACL::canCopy($username, $perms, $folder);
$gExtractBase = $isAdmin || ACL::canExtract($username, $perms, $folder);
$gShareFile = $isAdmin || ACL::canShareFile($username, $perms, $folder);
$gShareFolder = $isAdmin || ACL::canShareFolder($username, $perms, $folder);
$canView = $canViewBase && $inScope;
$canUpload = $gUploadBase && !$readOnly && !$disableUpload && $inScope;
$canCreate = $gCreateBase && !$readOnly && $inScope;
$canRename = $gRenameBase && !$readOnly && $inScope;
$canDelete = $gDeleteBase && !$readOnly && $inScope;
$canDeleteFile = $gDeleteBase && !$readOnly && $inScope;
$canDeleteFolder = !$readOnly && $inScope && (
$isAdmin ||
$isOwner ||
ACL::canManage($username, $perms, $folder) ||
$gDeleteBase // if your ACL::canDelete should also allow folder deletes
);
$canReceive = ($gUploadBase || $gCreateBase || $isAdmin) && !$readOnly && !$disableUpload && $inScope;
$canMoveIn = $canReceive;
$canEdit = $gEditBase && !$readOnly && $inScope;
$canCopy = $gCopyBase && !$readOnly && $inScope;
$canExtract = $gExtractBase && !$readOnly && $inScope;
$canShareEff = $canShareBase && $inScope;
$canShareFile = $gShareFile && $inScope;
$canShareFold = $gShareFolder && $inScope;
$isRoot = ($folder === 'root');
$canMoveFolder = false;
if ($isRoot) {
$canRename = false;
$canDelete = false;
$canShareFold = false;
} else {
$canMoveFolder = (ACL::canManage($username, $perms, $folder) || ACL::isOwner($username, $perms, $folder))
&& !$readOnly;
}
$owner = null;
try { if (class_exists('FolderModel') && method_exists('FolderModel','getOwnerFor')) $owner = FolderModel::getOwnerFor($folder); } catch (\Throwable $e) {}
return [
'user' => $username,
'folder' => $folder,
'isAdmin' => $isAdmin,
'flags' => [
'folderOnly' => $folderOnly,
'readOnly' => $readOnly,
'disableUpload' => $disableUpload,
],
'owner' => $owner,
'canView' => $canView,
'canViewOwn' => $canViewOwn,
'canUpload' => $canUpload,
'canCreate' => $canCreate,
'canRename' => $canRename,
'canDelete' => $canDeleteFile,
'canDeleteFolder' => $canDeleteFolder,
'canMoveIn' => $canMoveIn,
'canMove' => $canMoveIn, // legacy alias
'canMoveFolder' => $canMoveFolder,
'canEdit' => $canEdit,
'canCopy' => $canCopy,
'canExtract' => $canExtract,
'canShare' => $canShareEff, // legacy umbrella
'canShareFile' => $canShareFile,
'canShareFolder' => $canShareFold,
];
}
/* ---------------------------
Private helpers (caps)
----------------------------*/
private static function loadPermsFor(string $u): array {
try {
if (function_exists('loadUserPermissions')) {
$p = loadUserPermissions($u);
return is_array($p) ? $p : [];
}
if (class_exists('userModel') && method_exists('userModel', 'getUserPermissions')) {
$all = userModel::getUserPermissions();
if (is_array($all)) {
if (isset($all[$u])) return (array)$all[$u];
$lk = strtolower($u);
if (isset($all[$lk])) return (array)$all[$lk];
}
}
} catch (\Throwable $e) {}
return [];
}
private static function boolFrom(array $a, string ...$keys): bool {
foreach ($keys as $k) if (!empty($a[$k])) return true;
return false;
}
private static function isOwnerOrAncestorOwner(string $user, array $perms, string $folder): bool {
$f = ACL::normalizeFolder($folder);
if (ACL::isOwner($user, $perms, $f)) return true;
while ($f !== '' && strcasecmp($f, 'root') !== 0) {
$pos = strrpos($f, '/');
if ($pos === false) break;
$f = substr($f, 0, $pos);
if ($f === '' || strcasecmp($f, 'root') === 0) break;
if (ACL::isOwner($user, $perms, $f)) return true;
}
return false;
}
private static function inUserFolderScope(string $folder, string $u, array $perms, bool $isAdmin, bool $folderOnly): bool {
if ($isAdmin) return true;
if (!$folderOnly) return true; // normal users: global scope
$f = ACL::normalizeFolder($folder);
if ($f === 'root' || $f === '') {
return self::isOwnerOrAncestorOwner($u, $perms, $f);
}
if ($f === $u || str_starts_with($f, $u . '/')) return true;
return self::isOwnerOrAncestorOwner($u, $perms, $f);
}
private static function requireCsrf(): void
{
self::ensureSession();
@@ -1123,8 +1283,11 @@ class FolderController
$map = FolderMeta::getMap();
$out = [];
foreach ($map as $folder => $hex) {
$folder = FolderMeta::normalizeFolder($folder);
if (ACL::canRead($user, $perms, $folder)) $out[$folder] = $hex;
$folder = FolderMeta::normalizeFolder((string)$folder);
if ($folder === 'root') continue; // dont bother exposing root
if (ACL::canRead($user, $perms, $folder) || ACL::canReadOwn($user, $perms, $folder)) {
$out[$folder] = $hex;
}
}
echo json_encode($out, JSON_UNESCAPED_SLASHES);
}
@@ -1153,21 +1316,29 @@ class FolderController
$body = json_decode(file_get_contents('php://input') ?: "{}", true) ?: [];
$folder = FolderMeta::normalizeFolder((string)($body['folder'] ?? 'root'));
$color = isset($body['color']) ? (string)$body['color'] : '';
$raw = array_key_exists('color', $body) ? (string)$body['color'] : '';
// Treat “customize color” as rename-level capability (your convention)
if (!ACL::canRename($user, $perms, $folder)) {
if ($folder === 'root') {
http_response_code(400);
echo json_encode(['error' => 'Cannot set color on root']);
return;
}
// >>> Require canEdit (not canRename) <<<
if (!ACL::canEdit($user, $perms, $folder) && !ACL::isAdmin($perms)) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden']);
return;
}
try {
$res = FolderMeta::setColor($folder, $color === '' ? null : $color);
// empty string clears; non-empty must be valid #RGB or #RRGGBB
$hex = ($raw === '') ? null : FolderMeta::normalizeHex($raw);
$res = FolderMeta::setColor($folder, $hex);
echo json_encode(['success' => true] + $res, JSON_UNESCAPED_SLASHES);
} catch (\InvalidArgumentException $e) {
http_response_code(400);
echo json_encode(['error' => $e->getMessage()]);
echo json_encode(['error' => 'Invalid color']);
}
}

View File

@@ -44,9 +44,6 @@ class MediaController
$f = trim((string)$f);
return ($f==='' || strtolower($f)==='root') ? 'root' : $f;
}
private function validFolder($f): bool {
return $f==='root' || (bool)preg_match(REGEX_FOLDER_NAME, $f);
}
private function validFile($f): bool {
$f = basename((string)$f);
return $f !== '' && (bool)preg_match(REGEX_FILE_NAME, $f);
@@ -56,6 +53,24 @@ class MediaController
return ACL::canRead($username, $perms, $folder) ? null : "Forbidden";
}
private function validFolder($f): bool {
if ($f === 'root') return true;
// Validate per-segment against your REGEX_FOLDER_NAME
$parts = array_filter(explode('/', (string)$f), fn($p) => $p !== '');
if (!$parts) return false;
foreach ($parts as $seg) {
if (!preg_match(REGEX_FOLDER_NAME, $seg)) return false;
}
return true;
}
/** “View” means read OR read_own */
private function canViewFolder(string $folder, string $username): bool {
$perms = loadUserPermissions($username) ?: [];
return ACL::canRead($username, $perms, $folder)
|| ACL::canReadOwn($username, $perms, $folder);
}
/** POST /api/media/updateProgress.php */
public function updateProgress(): void {
$this->jsonStart();
@@ -67,15 +82,15 @@ class MediaController
$d = $this->readJson();
$folder = $this->normalizeFolder($d['folder'] ?? 'root');
$file = (string)($d['file'] ?? '');
$seconds = isset($d['seconds']) ? floatval($d['seconds']) : 0.0;
$duration = isset($d['duration']) ? floatval($d['duration']) : null;
$seconds = isset($d['seconds']) ? (float)$d['seconds'] : 0.0;
$duration = isset($d['duration']) ? (float)$d['duration'] : null;
$completed = isset($d['completed']) ? (bool)$d['completed'] : null;
$clear = isset($d['clear']) ? (bool)$d['clear'] : false;
$clear = !empty($d['clear']);
if (!$this->validFolder($folder) || !$this->validFile($file)) {
$this->out(['error'=>'Invalid folder/file'], 400); return;
}
if ($this->enforceRead($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
if (!$this->canViewFolder($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
if ($clear) {
$ok = MediaModel::clearProgress($u, $folder, $file);
@@ -102,7 +117,7 @@ class MediaController
if (!$this->validFolder($folder) || !$this->validFile($file)) {
$this->out(['error'=>'Invalid folder/file'], 400); return;
}
if ($this->enforceRead($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
if (!$this->canViewFolder($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
$row = MediaModel::getProgress($u, $folder, $file);
$this->out(['state'=>$row]);
@@ -123,7 +138,12 @@ class MediaController
if (!$this->validFolder($folder)) {
$this->out(['error'=>'Invalid folder'], 400); return;
}
if ($this->enforceRead($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
// Soft-fail for restricted users: avoid noisy console 403s
if (!$this->canViewFolder($folder, $u)) {
$this->out(['map' => []]); // 200 OK, no leakage
return;
}
$map = MediaModel::getFolderMap($u, $folder);
$this->out(['map'=>$map]);

87
src/lib/FS.php Normal file
View File

@@ -0,0 +1,87 @@
<?php
// src/lib/FS.php
declare(strict_types=1);
require_once PROJECT_ROOT . '/config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
final class FS
{
/** Hidden/system names to ignore entirely */
public static function IGNORE(): array {
return ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db'];
}
/** App-specific names to skip from UI */
public static function SKIP(): array {
return ['trash','profile_pics'];
}
public static function isSafeSegment(string $name): bool {
if ($name === '.' || $name === '..') return false;
if (strpos($name, '/') !== false || strpos($name, '\\') !== false) return false;
if (strpos($name, "\0") !== false) return false;
if (preg_match('/[\x00-\x1F]/u', $name)) return false;
$len = mb_strlen($name);
return $len > 0 && $len <= 255;
}
/** realpath($p) and ensure it remains inside $base (defends symlink escape). */
public static function safeReal(string $baseReal, string $p): ?string {
$rp = realpath($p);
if ($rp === false) return null;
$base = rtrim($baseReal, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$rp2 = rtrim($rp, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
if (strpos($rp2, $base) !== 0) return null;
return rtrim($rp, DIRECTORY_SEPARATOR);
}
/**
* Small bounded DFS to learn if an unreadable folder has any readable descendant (for “locked” rows).
* $maxDepth intentionally small to avoid expensive scans.
*/
public static function hasReadableDescendant(
string $baseReal,
string $absPath,
string $relPath,
string $user,
array $perms,
int $maxDepth = 2
): bool {
if ($maxDepth <= 0 || !is_dir($absPath)) return false;
$IGNORE = self::IGNORE();
$SKIP = self::SKIP();
$items = @scandir($absPath) ?: [];
foreach ($items as $child) {
if ($child === '.' || $child === '..') continue;
if ($child[0] === '.') continue;
if (in_array($child, $IGNORE, true)) continue;
if (!self::isSafeSegment($child)) continue;
$lower = strtolower($child);
if (in_array($lower, $SKIP, true)) continue;
$abs = $absPath . DIRECTORY_SEPARATOR . $child;
if (!@is_dir($abs)) continue;
// Resolve symlink safely
if (@is_link($abs)) {
$safe = self::safeReal($baseReal, $abs);
if ($safe === null || !is_dir($safe)) continue;
$abs = $safe;
}
$rel = ($relPath === 'root') ? $child : ($relPath . '/' . $child);
if (ACL::canRead($user, $perms, $rel) || ACL::canReadOwn($user, $perms, $rel)) {
return true;
}
if ($maxDepth > 1 && self::hasReadableDescendant($baseReal, $abs, $rel, $user, $perms, $maxDepth - 1)) {
return true;
}
}
return false;
}
}

View File

@@ -3,6 +3,7 @@
require_once PROJECT_ROOT . '/config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
require_once PROJECT_ROOT . '/src/lib/FS.php';
class FolderModel
{
@@ -10,44 +11,228 @@ class FolderModel
* Ownership mapping helpers (stored in META_DIR/folder_owners.json)
* ============================================================ */
public static function countVisible(string $folder, string $user, array $perms): array
{
// Normalize
$folder = ACL::normalizeFolder($folder);
// ACL gate: if you cant read, report empty (no leaks)
if (!$user || !ACL::canRead($user, $perms, $folder)) {
return ['folders' => 0, 'files' => 0];
}
// Resolve paths under UPLOAD_DIR
$root = rtrim((string)UPLOAD_DIR, '/\\');
$path = ($folder === 'root') ? $root : ($root . '/' . $folder);
$realRoot = @realpath($root);
$realPath = @realpath($path);
if ($realRoot === false || $realPath === false || strpos($realPath, $realRoot) !== 0) {
return ['folders' => 0, 'files' => 0];
}
// Count quickly, skipping UI-internal dirs
$folders = 0; $files = 0;
try {
foreach (new DirectoryIterator($realPath) as $f) {
if ($f->isDot()) continue;
$name = $f->getFilename();
if ($name === 'trash' || $name === 'profile_pics') continue;
if ($f->isDir()) $folders++; else $files++;
if ($folders > 0 || $files > 0) break; // short-circuit: we only care if empty vs not
}
} catch (\Throwable $e) {
// Stay quiet + safe
$folders = 0; $files = 0;
}
return ['folders' => $folders, 'files' => $files];
}
public static function countVisible(string $folder, string $user, array $perms): array
{
$folder = ACL::normalizeFolder($folder);
// If the user can't view this folder at all, short-circuit (admin/read/read_own)
$canViewFolder = ACL::isAdmin($perms)
|| ACL::canRead($user, $perms, $folder)
|| ACL::canReadOwn($user, $perms, $folder);
if (!$canViewFolder) return ['folders' => 0, 'files' => 0];
$base = realpath((string)UPLOAD_DIR);
if ($base === false) return ['folders' => 0, 'files' => 0];
// Resolve target dir + ACL-relative prefix
if ($folder === 'root') {
$dir = $base;
$relPrefix = '';
} else {
$parts = array_filter(explode('/', $folder), fn($p) => $p !== '');
foreach ($parts as $seg) {
if (!self::isSafeSegment($seg)) return ['folders' => 0, 'files' => 0];
}
$guess = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
$dir = self::safeReal($base, $guess);
if ($dir === null || !is_dir($dir)) return ['folders' => 0, 'files' => 0];
$relPrefix = implode('/', $parts);
}
// Ignore lists (expandable)
$IGNORE = ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db'];
$SKIP = ['trash', 'profile_pics'];
$entries = @scandir($dir);
if ($entries === false) return ['folders' => 0, 'files' => 0];
$hasChildFolder = false;
$hasFile = false;
// Cap scanning to avoid pathological dirs
$MAX_SCAN = 4000;
$scanned = 0;
foreach ($entries as $name) {
if (++$scanned > $MAX_SCAN) break;
if ($name === '.' || $name === '..') continue;
if ($name[0] === '.') continue;
if (in_array($name, $IGNORE, true)) continue;
if (in_array(strtolower($name), $SKIP, true)) continue;
if (!self::isSafeSegment($name)) continue;
$abs = $dir . DIRECTORY_SEPARATOR . $name;
if (@is_dir($abs)) {
// Symlink defense on children
if (@is_link($abs)) {
$safe = self::safeReal($base, $abs);
if ($safe === null || !is_dir($safe)) continue;
}
// Only count child dirs the user can view (admin/read/read_own)
$childRel = ($relPrefix === '' ? $name : $relPrefix . '/' . $name);
if (
ACL::isAdmin($perms)
|| ACL::canRead($user, $perms, $childRel)
|| ACL::canReadOwn($user, $perms, $childRel)
) {
$hasChildFolder = true;
}
} elseif (@is_file($abs)) {
// Any file present is enough for the "files" flag once the folder itself is viewable
$hasFile = true;
}
if ($hasChildFolder && $hasFile) break; // early exit
}
return [
'folders' => $hasChildFolder ? 1 : 0,
'files' => $hasFile ? 1 : 0,
];
}
/* Helpers (private) */
private static function isSafeSegment(string $name): bool
{
if ($name === '.' || $name === '..') return false;
if (strpos($name, '/') !== false || strpos($name, '\\') !== false) return false;
if (strpos($name, "\0") !== false) return false;
if (preg_match('/[\x00-\x1F]/u', $name)) return false;
$len = mb_strlen($name);
return $len > 0 && $len <= 255;
}
private static function safeReal(string $baseReal, string $p): ?string
{
$rp = realpath($p);
if ($rp === false) return null;
$base = rtrim($baseReal, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$rp2 = rtrim($rp, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
if (strpos($rp2, $base) !== 0) return null;
return rtrim($rp, DIRECTORY_SEPARATOR);
}
public static function listChildren(string $folder, string $user, array $perms, ?string $cursor = null, int $limit = 500): array
{
$folder = ACL::normalizeFolder($folder);
$limit = max(1, min(2000, $limit));
$cursor = ($cursor !== null && $cursor !== '') ? $cursor : null;
$baseReal = realpath((string)UPLOAD_DIR);
if ($baseReal === false) return ['items' => [], 'nextCursor' => null];
// Resolve target directory
if ($folder === 'root') {
$dirReal = $baseReal;
$relPrefix = 'root';
} else {
$parts = array_filter(explode('/', $folder), fn($p) => $p !== '');
foreach ($parts as $seg) {
if (!FS::isSafeSegment($seg)) return ['items'=>[], 'nextCursor'=>null];
}
$relPrefix = implode('/', $parts);
$dirGuess = $baseReal . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
$dirReal = FS::safeReal($baseReal, $dirGuess);
if ($dirReal === null || !is_dir($dirReal)) return ['items'=>[], 'nextCursor'=>null];
}
$IGNORE = FS::IGNORE();
$SKIP = FS::SKIP(); // lowercased names to skip (e.g. 'trash', 'profile_pics')
$entries = @scandir($dirReal);
if ($entries === false) return ['items'=>[], 'nextCursor'=>null];
$rows = []; // each: ['name'=>..., 'locked'=>bool, 'hasSubfolders'=>bool?, 'nonEmpty'=>bool?]
foreach ($entries as $item) {
if ($item === '.' || $item === '..') continue;
if ($item[0] === '.') continue;
if (in_array($item, $IGNORE, true)) continue;
if (!FS::isSafeSegment($item)) continue;
$lower = strtolower($item);
if (in_array($lower, $SKIP, true)) continue;
$full = $dirReal . DIRECTORY_SEPARATOR . $item;
if (!@is_dir($full)) continue;
// Symlink defense
if (@is_link($full)) {
$safe = FS::safeReal($baseReal, $full);
if ($safe === null || !is_dir($safe)) continue;
$full = $safe;
}
// ACL-relative path (for checks)
$rel = ($relPrefix === 'root') ? $item : $relPrefix . '/' . $item;
$canView = ACL::canRead($user, $perms, $rel) || ACL::canReadOwn($user, $perms, $rel);
$locked = !$canView;
// ---- quick per-child stats (single-level scan, early exit) ----
$hasSubs = false; // at least one subdirectory
$nonEmpty = false; // any direct entry (file or folder)
try {
$it = new \FilesystemIterator($full, \FilesystemIterator::SKIP_DOTS);
foreach ($it as $child) {
$name = $child->getFilename();
if (!$name) continue;
if ($name[0] === '.') continue;
if (!FS::isSafeSegment($name)) continue;
if (in_array(strtolower($name), $SKIP, true)) continue;
$nonEmpty = true;
$isDir = $child->isDir();
if (!$isDir && $child->isLink()) {
$linkReal = FS::safeReal($baseReal, $child->getPathname());
$isDir = ($linkReal !== null && is_dir($linkReal));
}
if ($isDir) { $hasSubs = true; break; } // early exit once we know there's a subfolder
}
} catch (\Throwable $e) {
// keep defaults
}
// ---------------------------------------------------------------
if ($locked) {
// Show a locked row ONLY when this folder has a readable descendant
if (FS::hasReadableDescendant($baseReal, $full, $rel, $user, $perms, 2)) {
$rows[] = [
'name' => $item,
'locked' => true,
'hasSubfolders' => $hasSubs, // fine to keep structural chevrons
// nonEmpty intentionally omitted for locked nodes
];
}
} else {
$rows[] = [
'name' => $item,
'locked' => false,
'hasSubfolders' => $hasSubs,
'nonEmpty' => $nonEmpty,
];
}
}
// natural order + cursor pagination
usort($rows, fn($a, $b) => strnatcasecmp($a['name'], $b['name']));
$start = 0;
if ($cursor !== null) {
$n = count($rows);
for ($i = 0; $i < $n; $i++) {
if (strnatcasecmp($rows[$i]['name'], $cursor) > 0) { $start = $i; break; }
$start = $i + 1;
}
}
$page = array_slice($rows, $start, $limit);
$nextCursor = null;
if ($start + count($page) < count($rows)) {
$last = $page[count($page)-1];
$nextCursor = $last['name'];
}
return ['items' => $page, 'nextCursor' => $nextCursor];
}
/** Load the folder → owner map. */
public static function getFolderOwners(): array
@@ -213,40 +398,42 @@ class FolderModel
// -------- Normalize incoming values (use ONLY the parameters) --------
$folderName = trim((string)$folderName);
$parentIn = trim((string)$parent);
// If the client sent a path in folderName (e.g., "bob/new-sub") and parent is root/empty,
// derive parent = "bob" and folderName = "new-sub" so permission checks hit "bob".
$normalized = ACL::normalizeFolder($folderName);
if ($normalized !== 'root' && strpos($normalized, '/') !== false &&
($parentIn === '' || strcasecmp($parentIn, 'root') === 0)) {
if (
$normalized !== 'root' && strpos($normalized, '/') !== false &&
($parentIn === '' || strcasecmp($parentIn, 'root') === 0)
) {
$parentIn = trim(str_replace('\\', '/', dirname($normalized)), '/');
$folderName = basename($normalized);
if ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) $parentIn = 'root';
}
$parent = ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) ? 'root' : $parentIn;
$folderName = trim($folderName);
if ($folderName === '') return ['success'=>false, 'error' => 'Folder name required'];
if ($folderName === '') return ['success' => false, 'error' => 'Folder name required'];
// ACL key for new folder
$newKey = ($parent === 'root') ? $folderName : ($parent . '/' . $folderName);
// -------- Compose filesystem paths --------
$base = rtrim((string)UPLOAD_DIR, "/\\");
$parentRel = ($parent === 'root') ? '' : str_replace('/', DIRECTORY_SEPARATOR, $parent);
$parentAbs = $parentRel ? ($base . DIRECTORY_SEPARATOR . $parentRel) : $base;
$newAbs = $parentAbs . DIRECTORY_SEPARATOR . $folderName;
// -------- Exists / sanity checks --------
if (!is_dir($parentAbs)) return ['success'=>false, 'error' => 'Parent folder does not exist'];
if (is_dir($newAbs)) return ['success'=>false, 'error' => 'Folder already exists'];
if (!is_dir($parentAbs)) return ['success' => false, 'error' => 'Parent folder does not exist'];
if (is_dir($newAbs)) return ['success' => false, 'error' => 'Folder already exists'];
// -------- Create directory --------
if (!@mkdir($newAbs, 0775, true)) {
$err = error_get_last();
return ['success'=>false, 'error' => 'Failed to create folder' . (!empty($err['message']) ? (': '.$err['message']) : '')];
return ['success' => false, 'error' => 'Failed to create folder' . (!empty($err['message']) ? (': ' . $err['message']) : '')];
}
// -------- Seed ACL --------
$inherit = defined('ACL_INHERIT_ON_CREATE') && ACL_INHERIT_ON_CREATE;
try {
@@ -265,9 +452,9 @@ class FolderModel
} catch (Throwable $e) {
// Roll back FS if ACL seeding fails
@rmdir($newAbs);
return ['success'=>false, 'error' => 'Failed to seed ACL: ' . $e->getMessage()];
return ['success' => false, 'error' => 'Failed to seed ACL: ' . $e->getMessage()];
}
return ['success' => true, 'folder' => $newKey];
}
@@ -318,7 +505,7 @@ class FolderModel
// Validate names (per-segment)
foreach ([$oldFolder, $newFolder] as $f) {
$parts = array_filter(explode('/', $f), fn($p)=>$p!=='');
$parts = array_filter(explode('/', $f), fn($p) => $p !== '');
if (empty($parts)) return ["error" => "Invalid folder name(s)."];
foreach ($parts as $seg) {
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
@@ -333,7 +520,7 @@ class FolderModel
$base = realpath(UPLOAD_DIR);
if ($base === false) return ["error" => "Uploads directory not configured correctly."];
$newParts = array_filter(explode('/', $newFolder), fn($p) => $p!=='');
$newParts = array_filter(explode('/', $newFolder), fn($p) => $p !== '');
$newRel = implode('/', $newParts);
$newPath = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $newParts);
@@ -508,7 +695,7 @@ class FolderModel
return [
"record" => $record,
"folder" => $relative,
"realFolderPath"=> $realFolderPath,
"realFolderPath" => $realFolderPath,
"files" => $filesOnPage,
"currentPage" => $currentPage,
"totalPages" => $totalPages
@@ -532,7 +719,7 @@ class FolderModel
}
$expires = time() + max(1, $expirationSeconds);
$hashedPassword= $password !== "" ? password_hash($password, PASSWORD_DEFAULT) : "";
$hashedPassword = $password !== "" ? password_hash($password, PASSWORD_DEFAULT) : "";
$shareFile = META_DIR . "share_folder_links.json";
$links = file_exists($shareFile)
@@ -560,7 +747,7 @@ class FolderModel
// Build URL
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
$scheme = $https ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? gethostbyname(gethostname());
$baseUrl = $scheme . '://' . rtrim($host, '/');
@@ -587,7 +774,7 @@ class FolderModel
return ["error" => "This share link has expired."];
}
[$realFolderPath, , $err] = self::resolveFolderPath((string)$record['folder'], false);
[$realFolderPath,, $err] = self::resolveFolderPath((string)$record['folder'], false);
if ($err || !is_dir($realFolderPath)) {
return ["error" => "Shared folder not found."];
}
@@ -615,8 +802,26 @@ class FolderModel
// Max size & allowed extensions (mirror FileModels common types)
$maxSize = 50 * 1024 * 1024; // 50 MB
$allowedExtensions = [
'jpg','jpeg','png','gif','pdf','doc','docx','txt','xls','xlsx','ppt','pptx',
'mp4','webm','mp3','mkv','csv','json','xml','md'
'jpg',
'jpeg',
'png',
'gif',
'pdf',
'doc',
'docx',
'txt',
'xls',
'xlsx',
'ppt',
'pptx',
'mp4',
'webm',
'mp3',
'mkv',
'csv',
'json',
'xml',
'md'
];
$shareFile = META_DIR . "share_folder_links.json";
@@ -655,7 +860,7 @@ class FolderModel
// New safe filename
$safeBase = preg_replace('/[^A-Za-z0-9_\-\.]/', '_', $uploadedName);
$newFilename= uniqid('', true) . "_" . $safeBase;
$newFilename = uniqid('', true) . "_" . $safeBase;
$targetPath = $targetDir . DIRECTORY_SEPARATOR . $newFilename;
if (!move_uploaded_file($fileUpload['tmp_name'], $targetPath)) {
@@ -697,4 +902,4 @@ class FolderModel
file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT), LOCK_EX);
return true;
}
}
}