1013 lines
38 KiB
PHP
1013 lines
38 KiB
PHP
<?php
|
||
// src/models/FolderModel.php
|
||
|
||
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
|
||
{
|
||
/* ============================================================
|
||
* Ownership mapping helpers (stored in META_DIR/folder_owners.json)
|
||
* ============================================================ */
|
||
|
||
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, 'bytes' => 0];
|
||
}
|
||
|
||
// NEW: distinguish full read vs own-only for this folder
|
||
$hasFullRead = ACL::isAdmin($perms) || ACL::canRead($user, $perms, $folder);
|
||
// if !$hasFullRead but $canViewFolder is true, they’re effectively "view own" only
|
||
|
||
$base = realpath((string)UPLOAD_DIR);
|
||
if ($base === false) {
|
||
return ['folders' => 0, 'files' => 0, 'bytes' => 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, 'bytes' => 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, 'bytes' => 0];
|
||
}
|
||
$relPrefix = implode('/', $parts);
|
||
}
|
||
|
||
$IGNORE = ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db'];
|
||
$SKIP = ['trash', 'profile_pics'];
|
||
|
||
$entries = @scandir($dir);
|
||
if ($entries === false) {
|
||
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
|
||
}
|
||
|
||
$folderCount = 0;
|
||
$fileCount = 0;
|
||
$totalBytes = 0;
|
||
|
||
// NEW: stats for created / modified
|
||
$earliestUploaded = null; // min mtime
|
||
$latestMtime = null; // max mtime
|
||
|
||
$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)) {
|
||
if (@is_link($abs)) {
|
||
$safe = self::safeReal($base, $abs);
|
||
if ($safe === null || !is_dir($safe)) {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
$childRel = ($relPrefix === '' ? $name : $relPrefix . '/' . $name);
|
||
if (
|
||
ACL::isAdmin($perms)
|
||
|| ACL::canRead($user, $perms, $childRel)
|
||
|| ACL::canReadOwn($user, $perms, $childRel)
|
||
) {
|
||
$folderCount++;
|
||
}
|
||
} elseif (@is_file($abs)) {
|
||
// Only count files if the user has full read on *this* folder.
|
||
// If they’re view_own-only here, don’t leak or mis-report counts.
|
||
if (!$hasFullRead) {
|
||
continue;
|
||
}
|
||
|
||
$fileCount++;
|
||
$sz = @filesize($abs);
|
||
if (is_int($sz) && $sz > 0) {
|
||
$totalBytes += $sz;
|
||
}
|
||
|
||
// NEW: track earliest / latest mtime from visible files
|
||
$mt = @filemtime($abs);
|
||
if (is_int($mt) && $mt > 0) {
|
||
if ($earliestUploaded === null || $mt < $earliestUploaded) {
|
||
$earliestUploaded = $mt;
|
||
}
|
||
if ($latestMtime === null || $mt > $latestMtime) {
|
||
$latestMtime = $mt;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
$result = [
|
||
'folders' => $folderCount,
|
||
'files' => $fileCount,
|
||
'bytes' => $totalBytes,
|
||
];
|
||
|
||
// Only include when we actually saw at least one readable file
|
||
if ($earliestUploaded !== null) {
|
||
$result['earliest_uploaded'] = date(DATE_TIME_FORMAT, $earliestUploaded);
|
||
}
|
||
if ($latestMtime !== null) {
|
||
$result['latest_mtime'] = date(DATE_TIME_FORMAT, $latestMtime);
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/* 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
|
||
{
|
||
$f = FOLDER_OWNERS_FILE;
|
||
if (!file_exists($f)) return [];
|
||
$json = json_decode(@file_get_contents($f), true);
|
||
return is_array($json) ? $json : [];
|
||
}
|
||
|
||
/** Persist the folder → owner map. */
|
||
public static function saveFolderOwners(array $map): bool
|
||
{
|
||
return (bool) @file_put_contents(FOLDER_OWNERS_FILE, json_encode($map, JSON_PRETTY_PRINT), LOCK_EX);
|
||
}
|
||
|
||
/** Set (or replace) the owner for a specific folder (relative path or 'root'). */
|
||
public static function setOwnerFor(string $folder, string $owner): void
|
||
{
|
||
$key = trim($folder, "/\\ ");
|
||
$key = ($key === '' ? 'root' : $key);
|
||
$owners = self::getFolderOwners();
|
||
$owners[$key] = $owner;
|
||
self::saveFolderOwners($owners);
|
||
}
|
||
|
||
/** Get the owner for a folder (relative path or 'root'); returns null if unmapped. */
|
||
public static function getOwnerFor(string $folder): ?string
|
||
{
|
||
$key = trim($folder, "/\\ ");
|
||
$key = ($key === '' ? 'root' : $key);
|
||
$owners = self::getFolderOwners();
|
||
return $owners[$key] ?? null;
|
||
}
|
||
|
||
/** Rename a single ownership key (old → new). */
|
||
public static function renameOwnerKey(string $old, string $new): void
|
||
{
|
||
$old = trim($old, "/\\ ");
|
||
$new = trim($new, "/\\ ");
|
||
$owners = self::getFolderOwners();
|
||
if (isset($owners[$old])) {
|
||
$owners[$new] = $owners[$old];
|
||
unset($owners[$old]);
|
||
self::saveFolderOwners($owners);
|
||
}
|
||
}
|
||
|
||
/** Remove ownership for a folder and all its descendants. */
|
||
public static function removeOwnerForTree(string $folder): void
|
||
{
|
||
$folder = trim($folder, "/\\ ");
|
||
$owners = self::getFolderOwners();
|
||
foreach (array_keys($owners) as $k) {
|
||
if ($k === $folder || strpos($k, $folder . '/') === 0) {
|
||
unset($owners[$k]);
|
||
}
|
||
}
|
||
self::saveFolderOwners($owners);
|
||
}
|
||
|
||
/** Rename ownership keys for an entire subtree: old/... → new/... */
|
||
public static function renameOwnersForTree(string $oldFolder, string $newFolder): void
|
||
{
|
||
$old = trim($oldFolder, "/\\ ");
|
||
$new = trim($newFolder, "/\\ ");
|
||
$owners = self::getFolderOwners();
|
||
|
||
$rebased = [];
|
||
foreach ($owners as $k => $v) {
|
||
if ($k === $old || strpos($k, $old . '/') === 0) {
|
||
$suffix = substr($k, strlen($old));
|
||
// ensure no leading slash duplication
|
||
$suffix = ltrim($suffix, '/');
|
||
$rebased[$new . ($suffix !== '' ? '/' . $suffix : '')] = $v;
|
||
} else {
|
||
$rebased[$k] = $v;
|
||
}
|
||
}
|
||
self::saveFolderOwners($rebased);
|
||
}
|
||
|
||
/* ============================================================
|
||
* Existing helpers
|
||
* ============================================================ */
|
||
|
||
/**
|
||
* Resolve a (possibly nested) relative folder like "invoices/2025" to a real path
|
||
* under UPLOAD_DIR. Validates each path segment against REGEX_FOLDER_NAME, enforces
|
||
* containment, and (optionally) creates the folder.
|
||
*
|
||
* @param string $folder Relative folder or "root"
|
||
* @param bool $create Create the folder if missing
|
||
* @return array [string|null $realPath, string $relative, string|null $error]
|
||
*/
|
||
private static function resolveFolderPath(string $folder, bool $create = false): array
|
||
{
|
||
$folder = trim($folder) ?: 'root';
|
||
$relative = 'root';
|
||
|
||
$base = realpath(UPLOAD_DIR);
|
||
if ($base === false) {
|
||
return [null, 'root', "Uploads directory not configured correctly."];
|
||
}
|
||
|
||
if (strtolower($folder) === 'root') {
|
||
$dir = $base;
|
||
} else {
|
||
// validate each segment against REGEX_FOLDER_NAME
|
||
$parts = array_filter(explode('/', trim($folder, "/\\ ")), fn($p) => $p !== '');
|
||
if (empty($parts)) {
|
||
return [null, 'root', "Invalid folder name."];
|
||
}
|
||
foreach ($parts as $seg) {
|
||
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
|
||
return [null, 'root', "Invalid folder name."];
|
||
}
|
||
}
|
||
$relative = implode('/', $parts);
|
||
$dir = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
|
||
}
|
||
|
||
if (!is_dir($dir)) {
|
||
if ($create) {
|
||
if (!mkdir($dir, 0775, true)) {
|
||
return [null, $relative, "Failed to create folder."];
|
||
}
|
||
} else {
|
||
return [null, $relative, "Folder does not exist."];
|
||
}
|
||
}
|
||
|
||
$real = realpath($dir);
|
||
if ($real === false || strpos($real, $base) !== 0) {
|
||
return [null, $relative, "Invalid folder path."];
|
||
}
|
||
|
||
return [$real, $relative, null];
|
||
}
|
||
|
||
/** Build metadata file path for a given (relative) folder. */
|
||
private static function getMetadataFilePath(string $folder): string
|
||
{
|
||
if (strtolower($folder) === 'root' || trim($folder) === '') {
|
||
return META_DIR . "root_metadata.json";
|
||
}
|
||
return META_DIR . str_replace(['/', '\\', ' '], '-', trim($folder)) . '_metadata.json';
|
||
}
|
||
|
||
/**
|
||
* Creates a folder under the specified parent (or in root) and creates an empty metadata file.
|
||
* Also records the creator as the owner (if a session user is available).
|
||
*/
|
||
|
||
/**
|
||
* Create a folder on disk and register it in ACL with the creator as owner.
|
||
* @param string $folderName leaf name
|
||
* @param string $parent 'root' or nested key (e.g. 'team/reports')
|
||
* @param string $creator username to set as initial owner (falls back to 'admin')
|
||
*/
|
||
public static function createFolder(string $folderName, string $parent, string $creator): array
|
||
{
|
||
// -------- 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)
|
||
) {
|
||
$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'];
|
||
|
||
// 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'];
|
||
|
||
// -------- Create directory --------
|
||
if (!@mkdir($newAbs, 0775, true)) {
|
||
$err = error_get_last();
|
||
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 {
|
||
if ($inherit) {
|
||
// Copy parent’s explicit (legacy 5 buckets), add creator to owners
|
||
$p = ACL::explicit($parent); // owners, read, write, share, read_own
|
||
$owners = array_values(array_unique(array_map('strval', array_merge($p['owners'], [$creator]))));
|
||
$read = $p['read'];
|
||
$write = $p['write'];
|
||
$share = $p['share'];
|
||
ACL::upsert($newKey, $owners, $read, $write, $share);
|
||
} else {
|
||
// Creator owns the new folder
|
||
ACL::ensureFolderRecord($newKey, $creator);
|
||
}
|
||
} catch (Throwable $e) {
|
||
// Roll back FS if ACL seeding fails
|
||
@rmdir($newAbs);
|
||
return ['success' => false, 'error' => 'Failed to seed ACL: ' . $e->getMessage()];
|
||
}
|
||
|
||
return ['success' => true, 'folder' => $newKey];
|
||
}
|
||
|
||
|
||
public static function deleteFolderRecursiveAdmin(string $folder): array
|
||
{
|
||
if (strtolower($folder) === 'root') {
|
||
return ['error' => 'Cannot delete root folder.'];
|
||
}
|
||
|
||
[$real, $relative, $err] = self::resolveFolderPath($folder, false);
|
||
if ($err) return ['error' => $err];
|
||
|
||
if (!is_dir($real)) {
|
||
return ['error' => 'Folder not found.'];
|
||
}
|
||
|
||
$errors = [];
|
||
|
||
$it = new \RecursiveIteratorIterator(
|
||
new \RecursiveDirectoryIterator($real, \FilesystemIterator::SKIP_DOTS),
|
||
\RecursiveIteratorIterator::CHILD_FIRST
|
||
);
|
||
|
||
foreach ($it as $path => $info) {
|
||
if ($info->isDir()) {
|
||
if (!@rmdir($path)) {
|
||
$errors[] = "Failed to delete directory: {$path}";
|
||
}
|
||
} else {
|
||
if (!@unlink($path)) {
|
||
$errors[] = "Failed to delete file: {$path}";
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!@rmdir($real)) {
|
||
$errors[] = "Failed to delete directory: {$real}";
|
||
}
|
||
|
||
// Remove metadata JSONs for this subtree
|
||
$relative = trim($relative, "/\\ ");
|
||
if ($relative !== '' && $relative !== 'root') {
|
||
$prefix = str_replace(['/', '\\', ' '], '-', $relative);
|
||
$globPat = META_DIR . $prefix . '*_metadata.json';
|
||
$metaFiles = glob($globPat) ?: [];
|
||
foreach ($metaFiles as $mf) {
|
||
@unlink($mf);
|
||
}
|
||
}
|
||
|
||
// Remove ownership mappings for the subtree.
|
||
self::removeOwnerForTree($relative);
|
||
|
||
if ($errors) {
|
||
return ['error' => implode('; ', $errors)];
|
||
}
|
||
|
||
return ['success' => 'Folder and all contents deleted.'];
|
||
}
|
||
|
||
|
||
/**
|
||
* Deletes a folder if it is empty and removes its corresponding metadata.
|
||
* Also removes ownership mappings for this folder and all its descendants.
|
||
*/
|
||
public static function deleteFolder(string $folder): array
|
||
{
|
||
if (strtolower($folder) === 'root') {
|
||
return ["error" => "Cannot delete root folder."];
|
||
}
|
||
|
||
[$real, $relative, $err] = self::resolveFolderPath($folder, false);
|
||
if ($err) return ["error" => $err];
|
||
|
||
// Prevent deletion if not empty.
|
||
$items = array_diff(@scandir($real) ?: [], array('.', '..'));
|
||
if (count($items) > 0) {
|
||
return ["error" => "Folder is not empty."];
|
||
}
|
||
|
||
if (!@rmdir($real)) {
|
||
return ["error" => "Failed to delete folder."];
|
||
}
|
||
|
||
// Remove metadata file (best-effort).
|
||
$metadataFile = self::getMetadataFilePath($relative);
|
||
if (file_exists($metadataFile)) {
|
||
@unlink($metadataFile);
|
||
}
|
||
|
||
// Remove ownership mappings for the subtree.
|
||
self::removeOwnerForTree($relative);
|
||
|
||
return ["success" => true];
|
||
}
|
||
|
||
/**
|
||
* Renames a folder and updates related metadata files (by renaming their filenames).
|
||
* Also rewrites ownership keys for the whole subtree from old → new.
|
||
*/
|
||
public static function renameFolder(string $oldFolder, string $newFolder): array
|
||
{
|
||
$oldFolder = trim($oldFolder, "/\\ ");
|
||
$newFolder = trim($newFolder, "/\\ ");
|
||
|
||
// Validate names (per-segment)
|
||
foreach ([$oldFolder, $newFolder] as $f) {
|
||
$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)) {
|
||
return ["error" => "Invalid folder name(s)."];
|
||
}
|
||
}
|
||
}
|
||
|
||
[$oldReal, $oldRel, $err] = self::resolveFolderPath($oldFolder, false);
|
||
if ($err) return ["error" => $err];
|
||
|
||
$base = realpath(UPLOAD_DIR);
|
||
if ($base === false) return ["error" => "Uploads directory not configured correctly."];
|
||
|
||
$newParts = array_filter(explode('/', $newFolder), fn($p) => $p !== '');
|
||
$newRel = implode('/', $newParts);
|
||
$newPath = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $newParts);
|
||
|
||
// Parent of new path must exist
|
||
$newParent = dirname($newPath);
|
||
if (!is_dir($newParent) || strpos(realpath($newParent), $base) !== 0) {
|
||
return ["error" => "Invalid folder path."];
|
||
}
|
||
if (file_exists($newPath)) {
|
||
return ["error" => "New folder name already exists."];
|
||
}
|
||
|
||
if (!@rename($oldReal, $newPath)) {
|
||
return ["error" => "Failed to rename folder."];
|
||
}
|
||
|
||
// Update metadata filenames (prefix-rename)
|
||
$oldPrefix = str_replace(['/', '\\', ' '], '-', $oldRel);
|
||
$newPrefix = str_replace(['/', '\\', ' '], '-', $newRel);
|
||
$globPat = META_DIR . $oldPrefix . '*_metadata.json';
|
||
$metadataFiles = glob($globPat) ?: [];
|
||
|
||
foreach ($metadataFiles as $oldMetaFile) {
|
||
$baseName = basename($oldMetaFile);
|
||
$newBase = preg_replace('/^' . preg_quote($oldPrefix, '/') . '/', $newPrefix, $baseName);
|
||
$newMeta = META_DIR . $newBase;
|
||
@rename($oldMetaFile, $newMeta);
|
||
}
|
||
|
||
// 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];
|
||
}
|
||
|
||
/**
|
||
* Recursively scans a directory for subfolders (relative paths).
|
||
*/
|
||
private static function getSubfolders(string $dir, string $relative = ''): array
|
||
{
|
||
$folders = [];
|
||
$items = @scandir($dir) ?: [];
|
||
foreach ($items as $item) {
|
||
if ($item === '.' || $item === '..') continue;
|
||
if (!preg_match(REGEX_FOLDER_NAME, $item)) continue;
|
||
|
||
$path = $dir . DIRECTORY_SEPARATOR . $item;
|
||
if (is_dir($path)) {
|
||
$folderPath = ($relative ? $relative . '/' : '') . $item;
|
||
$folders[] = $folderPath;
|
||
$folders = array_merge($folders, self::getSubfolders($path, $folderPath));
|
||
}
|
||
}
|
||
return $folders;
|
||
}
|
||
|
||
/**
|
||
* Retrieves the list of folders (including "root") along with file count metadata.
|
||
* (Ownership filtering is handled in the controller; this function remains unchanged.)
|
||
*/
|
||
public static function getFolderList($ignoredParent = null, ?string $username = null, array $perms = []): array
|
||
{
|
||
$baseDir = realpath(UPLOAD_DIR);
|
||
if ($baseDir === false) {
|
||
return []; // or ["error" => "..."]
|
||
}
|
||
|
||
$folderInfoList = [];
|
||
|
||
// root
|
||
$rootMetaFile = self::getMetadataFilePath('root');
|
||
$rootFileCount = 0;
|
||
if (file_exists($rootMetaFile)) {
|
||
$rootMetadata = json_decode(file_get_contents($rootMetaFile), true);
|
||
$rootFileCount = is_array($rootMetadata) ? count($rootMetadata) : 0;
|
||
}
|
||
$folderInfoList[] = [
|
||
"folder" => "root",
|
||
"fileCount" => $rootFileCount,
|
||
"metadataFile" => basename($rootMetaFile)
|
||
];
|
||
|
||
// subfolders
|
||
$subfolders = is_dir($baseDir) ? self::getSubfolders($baseDir) : [];
|
||
foreach ($subfolders as $folder) {
|
||
$metaFile = self::getMetadataFilePath($folder);
|
||
$fileCount = 0;
|
||
if (file_exists($metaFile)) {
|
||
$metadata = json_decode(file_get_contents($metaFile), true);
|
||
$fileCount = is_array($metadata) ? count($metadata) : 0;
|
||
}
|
||
$folderInfoList[] = [
|
||
"folder" => $folder,
|
||
"fileCount" => $fileCount,
|
||
"metadataFile" => basename($metaFile)
|
||
];
|
||
}
|
||
|
||
if ($username !== null) {
|
||
$folderInfoList = array_values(array_filter(
|
||
$folderInfoList,
|
||
fn($row) => ACL::canRead($username, $perms, $row['folder'])
|
||
));
|
||
}
|
||
return $folderInfoList;
|
||
}
|
||
|
||
/**
|
||
* Retrieves the share folder record for a given token.
|
||
*/
|
||
public static function getShareFolderRecord(string $token): ?array
|
||
{
|
||
$shareFile = META_DIR . "share_folder_links.json";
|
||
if (!file_exists($shareFile)) return null;
|
||
$shareLinks = json_decode(file_get_contents($shareFile), true);
|
||
return (is_array($shareLinks) && isset($shareLinks[$token])) ? $shareLinks[$token] : null;
|
||
}
|
||
|
||
/**
|
||
* Retrieves shared folder data based on a share token.
|
||
*/
|
||
public static function getSharedFolderData(string $token, ?string $providedPass, int $page = 1, int $itemsPerPage = 10): array
|
||
{
|
||
$shareFile = META_DIR . "share_folder_links.json";
|
||
if (!file_exists($shareFile)) return ["error" => "Share link not found."];
|
||
|
||
$shareLinks = json_decode(file_get_contents($shareFile), true);
|
||
if (!is_array($shareLinks) || !isset($shareLinks[$token])) {
|
||
return ["error" => "Share link not found."];
|
||
}
|
||
$record = $shareLinks[$token];
|
||
|
||
if (time() > ($record['expires'] ?? 0)) {
|
||
return ["error" => "This share link has expired."];
|
||
}
|
||
|
||
if (!empty($record['password']) && empty($providedPass)) {
|
||
return ["needs_password" => true];
|
||
}
|
||
if (!empty($record['password']) && !password_verify($providedPass, $record['password'])) {
|
||
return ["error" => "Invalid password."];
|
||
}
|
||
|
||
// Resolve shared folder
|
||
$folder = trim((string)$record['folder'], "/\\ ");
|
||
[$realFolderPath, $relative, $err] = self::resolveFolderPath($folder === '' ? 'root' : $folder, false);
|
||
if ($err || !is_dir($realFolderPath)) {
|
||
return ["error" => "Shared folder not found."];
|
||
}
|
||
|
||
// List files (safe names only; skip hidden)
|
||
$all = @scandir($realFolderPath) ?: [];
|
||
$allFiles = [];
|
||
foreach ($all as $it) {
|
||
if ($it === '.' || $it === '..') continue;
|
||
if ($it[0] === '.') continue;
|
||
if (!preg_match(REGEX_FILE_NAME, $it)) continue;
|
||
if (is_file($realFolderPath . DIRECTORY_SEPARATOR . $it)) {
|
||
$allFiles[] = $it;
|
||
}
|
||
}
|
||
sort($allFiles, SORT_NATURAL | SORT_FLAG_CASE);
|
||
|
||
$totalFiles = count($allFiles);
|
||
$totalPages = max(1, (int)ceil($totalFiles / max(1, $itemsPerPage)));
|
||
$currentPage = min(max(1, $page), $totalPages);
|
||
$startIndex = ($currentPage - 1) * $itemsPerPage;
|
||
$filesOnPage = array_slice($allFiles, $startIndex, $itemsPerPage);
|
||
|
||
return [
|
||
"record" => $record,
|
||
"folder" => $relative,
|
||
"realFolderPath" => $realFolderPath,
|
||
"files" => $filesOnPage,
|
||
"currentPage" => $currentPage,
|
||
"totalPages" => $totalPages
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Creates a share link for a folder.
|
||
*/
|
||
public static function createShareFolderLink(string $folder, int $expirationSeconds = 3600, string $password = "", int $allowUpload = 0): array
|
||
{
|
||
// Validate folder (and ensure it exists)
|
||
[$real, $relative, $err] = self::resolveFolderPath($folder, false);
|
||
if ($err) return ["error" => $err];
|
||
|
||
// Token
|
||
try {
|
||
$token = bin2hex(random_bytes(16));
|
||
} catch (\Throwable $e) {
|
||
return ["error" => "Could not generate token."];
|
||
}
|
||
|
||
$expires = time() + max(1, $expirationSeconds);
|
||
$hashedPassword = $password !== "" ? password_hash($password, PASSWORD_DEFAULT) : "";
|
||
|
||
$shareFile = META_DIR . "share_folder_links.json";
|
||
$links = file_exists($shareFile)
|
||
? (json_decode(file_get_contents($shareFile), true) ?? [])
|
||
: [];
|
||
|
||
// cleanup expired
|
||
$now = time();
|
||
foreach ($links as $k => $v) {
|
||
if (!empty($v['expires']) && $v['expires'] < $now) {
|
||
unset($links[$k]);
|
||
}
|
||
}
|
||
|
||
$links[$token] = [
|
||
"folder" => $relative,
|
||
"expires" => $expires,
|
||
"password" => $hashedPassword,
|
||
"allowUpload" => $allowUpload ? 1 : 0
|
||
];
|
||
|
||
if (file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT), LOCK_EX) === false) {
|
||
return ["error" => "Could not save share link."];
|
||
}
|
||
|
||
// Build URL
|
||
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|
||
$scheme = $https ? 'https' : 'http';
|
||
$host = $_SERVER['HTTP_HOST'] ?? gethostbyname(gethostname());
|
||
$baseUrl = $scheme . '://' . rtrim($host, '/');
|
||
$link = $baseUrl . "/api/folder/shareFolder.php?token=" . urlencode($token);
|
||
|
||
return ["token" => $token, "expires" => $expires, "link" => $link];
|
||
}
|
||
|
||
/**
|
||
* Retrieves information for a shared file from a shared folder link.
|
||
*/
|
||
public static function getSharedFileInfo(string $token, string $file): array
|
||
{
|
||
$shareFile = META_DIR . "share_folder_links.json";
|
||
if (!file_exists($shareFile)) return ["error" => "Share link not found."];
|
||
|
||
$shareLinks = json_decode(file_get_contents($shareFile), true);
|
||
if (!is_array($shareLinks) || !isset($shareLinks[$token])) {
|
||
return ["error" => "Share link not found."];
|
||
}
|
||
$record = $shareLinks[$token];
|
||
|
||
if (time() > ($record['expires'] ?? 0)) {
|
||
return ["error" => "This share link has expired."];
|
||
}
|
||
|
||
[$realFolderPath,, $err] = self::resolveFolderPath((string)$record['folder'], false);
|
||
if ($err || !is_dir($realFolderPath)) {
|
||
return ["error" => "Shared folder not found."];
|
||
}
|
||
|
||
$file = basename(trim($file));
|
||
if (!preg_match(REGEX_FILE_NAME, $file)) {
|
||
return ["error" => "Invalid file name."];
|
||
}
|
||
|
||
$full = $realFolderPath . DIRECTORY_SEPARATOR . $file;
|
||
$real = realpath($full);
|
||
if ($real === false || strpos($real, $realFolderPath) !== 0 || !is_file($real)) {
|
||
return ["error" => "File not found."];
|
||
}
|
||
|
||
$mime = function_exists('mime_content_type') ? mime_content_type($real) : 'application/octet-stream';
|
||
return ["realFilePath" => $real, "mimeType" => $mime];
|
||
}
|
||
|
||
/**
|
||
* Handles uploading a file to a shared folder.
|
||
*/
|
||
public static function uploadToSharedFolder(string $token, array $fileUpload): array
|
||
{
|
||
// Max size & allowed extensions (mirror FileModel’s 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'
|
||
];
|
||
|
||
$shareFile = META_DIR . "share_folder_links.json";
|
||
if (!file_exists($shareFile)) {
|
||
return ["error" => "Share record not found."];
|
||
}
|
||
$shareLinks = json_decode(file_get_contents($shareFile), true);
|
||
if (!is_array($shareLinks) || !isset($shareLinks[$token])) {
|
||
return ["error" => "Invalid share token."];
|
||
}
|
||
$record = $shareLinks[$token];
|
||
|
||
if (time() > ($record['expires'] ?? 0)) {
|
||
return ["error" => "This share link has expired."];
|
||
}
|
||
if (empty($record['allowUpload']) || (int)$record['allowUpload'] !== 1) {
|
||
return ["error" => "File uploads are not allowed for this share."];
|
||
}
|
||
|
||
if (($fileUpload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||
return ["error" => "File upload error. Code: " . (int)$fileUpload['error']];
|
||
}
|
||
if (($fileUpload['size'] ?? 0) > $maxSize) {
|
||
return ["error" => "File size exceeds allowed limit."];
|
||
}
|
||
|
||
$uploadedName = basename((string)($fileUpload['name'] ?? ''));
|
||
$ext = strtolower(pathinfo($uploadedName, PATHINFO_EXTENSION));
|
||
if (!in_array($ext, $allowedExtensions, true)) {
|
||
return ["error" => "File type not allowed."];
|
||
}
|
||
|
||
// Resolve target folder
|
||
[$targetDir, $relative, $err] = self::resolveFolderPath((string)$record['folder'], true);
|
||
if ($err) return ["error" => $err];
|
||
|
||
// New safe filename
|
||
$safeBase = preg_replace('/[^A-Za-z0-9_\-\.]/', '_', $uploadedName);
|
||
$newFilename = uniqid('', true) . "_" . $safeBase;
|
||
$targetPath = $targetDir . DIRECTORY_SEPARATOR . $newFilename;
|
||
|
||
if (!move_uploaded_file($fileUpload['tmp_name'], $targetPath)) {
|
||
return ["error" => "Failed to move the uploaded file."];
|
||
}
|
||
|
||
// Update metadata (uploaded + modified + uploader)
|
||
$metadataFile = self::getMetadataFilePath($relative);
|
||
$meta = file_exists($metadataFile) ? (json_decode(file_get_contents($metadataFile), true) ?: []) : [];
|
||
|
||
$now = date(DATE_TIME_FORMAT);
|
||
$meta[$newFilename] = [
|
||
"uploaded" => $now,
|
||
"modified" => $now,
|
||
"uploader" => "Outside Share"
|
||
];
|
||
file_put_contents($metadataFile, json_encode($meta, JSON_PRETTY_PRINT), LOCK_EX);
|
||
|
||
return ["success" => "File uploaded successfully.", "newFilename" => $newFilename];
|
||
}
|
||
|
||
public static function getAllShareFolderLinks(): array
|
||
{
|
||
$shareFile = META_DIR . "share_folder_links.json";
|
||
if (!file_exists($shareFile)) return [];
|
||
$links = json_decode(file_get_contents($shareFile), true);
|
||
return is_array($links) ? $links : [];
|
||
}
|
||
|
||
public static function deleteShareFolderLink(string $token): bool
|
||
{
|
||
$shareFile = META_DIR . "share_folder_links.json";
|
||
if (!file_exists($shareFile)) return false;
|
||
|
||
$links = json_decode(file_get_contents($shareFile), true);
|
||
if (!is_array($links) || !isset($links[$token])) return false;
|
||
|
||
unset($links[$token]);
|
||
file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT), LOCK_EX);
|
||
return true;
|
||
}
|
||
}
|