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:
87
src/lib/FS.php
Normal file
87
src/lib/FS.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user