[ // 'key' => string, // 'parent' => string|null, // 'name' => string, // 'bytes' => int, // 'files' => int, // 'dirs' => int, // 'latest_mtime' => int // ] $folders = []; // Root entry $folders['root'] = [ 'key' => 'root', 'parent' => null, 'name' => 'root', 'bytes' => 0, 'files' => 0, 'dirs' => 0, 'latest_mtime' => 0, ]; // File records (we may trim to TOP_FILE_LIMIT later) // Each item: [ // 'folder' => folderKey, // 'name' => file name, // 'path' => "folder/name" or just name if root, // 'bytes' => int, // 'mtime' => int // ] $files = []; $rootLen = strlen($root); $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS ), RecursiveIteratorIterator::SELF_FIRST ); foreach ($it as $path => $info) { /** @var SplFileInfo $info */ $name = $info->getFilename(); // Skip dotfiles / dotdirs if ($name === '.' || $name === '..') { continue; } if ($name[0] === '.') { continue; } // Skip system/ignored entries if (in_array($name, $IGNORE, true)) { continue; } // Relative path under UPLOAD_DIR, normalized with '/' $rel = substr($path, $rootLen); $rel = str_replace('\\', '/', $rel); $rel = ltrim($rel, '/'); // Should only happen for the root itself, which we seeded if ($rel === '') { continue; } $isDir = $info->isDir(); if ($isDir) { $folderKey = $rel; $lowerRel = strtolower($folderKey); // Skip trash/profile_pics subtrees entirely if ($lowerRel === 'trash' || strpos($lowerRel, 'trash/') === 0) { $it->next(); continue; } if ($lowerRel === 'profile_pics' || strpos($lowerRel, 'profile_pics/') === 0) { $it->next(); continue; } // Skip SKIP entries at any level $baseLower = strtolower(basename($folderKey)); if (in_array($baseLower, $SKIP, true)) { $it->next(); continue; } // Register folder if (!isset($folders[$folderKey])) { $parent = self::parentKeyOf($folderKey); if (!isset($folders[$parent])) { // Ensure parent exists (important for aggregation step later) $folders[$parent] = [ 'key' => $parent, 'parent' => self::parentKeyOf($parent), 'name' => self::basenameKey($parent), 'bytes' => 0, 'files' => 0, 'dirs' => 0, 'latest_mtime' => 0, ]; } $folders[$folderKey] = [ 'key' => $folderKey, 'parent' => $parent, 'name' => self::basenameKey($folderKey), 'bytes' => 0, 'files' => 0, 'dirs' => 0, 'latest_mtime' => 0, ]; // Increment dir count on parent if ($parent !== null && isset($folders[$parent])) { $folders[$parent]['dirs']++; } } continue; } // File entry // Determine folder key where this file resides $relDir = str_replace('\\', '/', dirname($rel)); if ($relDir === '.' || $relDir === '') { $folderKey = 'root'; } else { $folderKey = $relDir; } $lowerFolder = strtolower($folderKey); if ($lowerFolder === 'trash' || strpos($lowerFolder, 'trash/') === 0) { continue; } if ($lowerFolder === 'profile_pics' || strpos($lowerFolder, 'profile_pics/') === 0) { continue; } // Skip SKIP entries for files inside unwanted app-specific dirs $baseLower = strtolower(basename($folderKey)); if (in_array($baseLower, $SKIP, true)) { continue; } // Ensure folder exists in map if (!isset($folders[$folderKey])) { $parent = self::parentKeyOf($folderKey); if (!isset($folders[$parent])) { $folders[$parent] = [ 'key' => $parent, 'parent' => self::parentKeyOf($parent), 'name' => self::basenameKey($parent), 'bytes' => 0, 'files' => 0, 'dirs' => 0, 'latest_mtime' => 0, ]; } $folders[$folderKey] = [ 'key' => $folderKey, 'parent' => $parent, 'name' => self::basenameKey($folderKey), 'bytes' => 0, 'files' => 0, 'dirs' => 0, 'latest_mtime' => 0, ]; if ($parent !== null && isset($folders[$parent])) { $folders[$parent]['dirs']++; } } $bytes = (int)$info->getSize(); $mtime = (int)$info->getMTime(); // Update folder leaf stats $folders[$folderKey]['bytes'] += $bytes; $folders[$folderKey]['files']++; if ($mtime > $folders[$folderKey]['latest_mtime']) { $folders[$folderKey]['latest_mtime'] = $mtime; } // Remember file record (we may trim later) $filePath = ($folderKey === 'root') ? $name : ($folderKey . '/' . $name); $files[] = [ 'folder' => $folderKey, 'name' => $name, 'path' => $filePath, 'bytes' => $bytes, 'mtime' => $mtime, ]; } // Aggregate folder bytes up the tree so each folder includes its descendants. // Process folders from deepest to shallowest. $keys = array_keys($folders); usort($keys, function (string $a, string $b): int { return self::depthOf($b) <=> self::depthOf($a); }); foreach ($keys as $key) { $parent = $folders[$key]['parent']; if ($parent !== null && isset($folders[$parent])) { $folders[$parent]['bytes'] += $folders[$key]['bytes']; $folders[$parent]['files'] += $folders[$key]['files']; $folders[$parent]['dirs'] += $folders[$key]['dirs']; $parentLatest = $folders[$parent]['latest_mtime']; if ($folders[$key]['latest_mtime'] > $parentLatest) { $folders[$parent]['latest_mtime'] = $folders[$key]['latest_mtime']; } } } // Root aggregate $rootBytes = isset($folders['root']) ? (int)$folders['root']['bytes'] : 0; $rootFiles = isset($folders['root']) ? (int)$folders['root']['files'] : 0; // Count of folders under the upload root (excluding "root" itself) $rootFolders = 0; if (!empty($folders)) { $rootFolders = max(0, count($folders) - 1); } // Trim top files list usort($files, function (array $a, array $b): int { // descending by bytes, then by path if ($a['bytes'] === $b['bytes']) { return strcmp($a['path'], $b['path']); } return ($a['bytes'] < $b['bytes']) ? 1 : -1; }); if (count($files) > self::TOP_FILE_LIMIT) { $files = array_slice($files, 0, self::TOP_FILE_LIMIT); } $snapshot = [ 'version' => 1, 'generated_at' => time(), 'scan_seconds' => microtime(true) - $start, 'root_bytes' => $rootBytes, 'root_files' => $rootFiles, 'root_folders' => $rootFolders, // Store folders as numerically-indexed array 'folders' => array_values($folders), 'files' => $files, ]; $path = self::snapshotPath(); $dir = dirname($path); if (!is_dir($dir)) { @mkdir($dir, 0775, true); } $json = json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); if ($json === false) { throw new RuntimeException('Failed to encode disk usage snapshot.'); } if (@file_put_contents($path, $json) === false) { throw new RuntimeException('Failed to write disk usage snapshot to ' . $path); } return $snapshot; } /** * Load the snapshot from disk, or return null if missing or invalid. */ public static function loadSnapshot(): ?array { $path = self::snapshotPath(); if (!is_file($path)) { return null; } $raw = @file_get_contents($path); if ($raw === false || $raw === '') { return null; } $data = json_decode($raw, true); if (!is_array($data)) { return null; } if (!isset($data['version']) || (int)$data['version'] !== 1) { return null; } return $data; } /** * Compute a lightweight summary for the Admin panel. * * @param int $maxTopFolders How many top folders to include. * @param int $maxTopFilesPreview Optional number of top files to include as preview. * @return array */ public static function getSummary(int $maxTopFolders = 5, int $maxTopFilesPreview = 0): array { $snapshot = self::loadSnapshot(); if ($snapshot === null) { return [ 'ok' => false, 'error' => 'no_snapshot', 'message' => 'No disk usage snapshot found. Run the disk usage scan to generate one.', 'generatedAt' => null, ]; } $rootBytes = (int)($snapshot['root_bytes'] ?? 0); $folders = is_array($snapshot['folders'] ?? null) ? $snapshot['folders'] : []; // --- Build "volumes" across core FileRise dirs (UPLOAD/USERS/META) --- $volumeRoots = [ 'uploads' => defined('UPLOAD_DIR') ? (string)UPLOAD_DIR : null, 'users' => defined('USERS_DIR') ? (string)USERS_DIR : null, 'meta' => defined('META_DIR') ? (string)META_DIR : null, ]; $volumesMap = []; $uploadReal = null; if (defined('UPLOAD_DIR')) { $tmp = realpath(UPLOAD_DIR); if ($tmp !== false && is_dir($tmp)) { $uploadReal = $tmp; } } foreach ($volumeRoots as $kind => $dir) { if ($dir === null || $dir === '') { continue; } $real = realpath($dir); if ($real === false || !is_dir($real)) { continue; } $total = @disk_total_space($real); $free = @disk_free_space($real); if ($total === false || $free === false || $total <= 0) { continue; } $total = (int)$total; $free = (int)$free; $used = $total - $free; if ($used < 0) { $used = 0; } $usedPct = ($used * 100.0) / $total; // Group by same total+free => assume same underlying volume $bucketKey = $total . ':' . $free; if (!isset($volumesMap[$bucketKey])) { $volumesMap[$bucketKey] = [ 'totalBytes' => $total, 'freeBytes' => $free, 'usedBytes' => $used, 'usedPercent' => $usedPct, 'roots' => [], ]; } $volumesMap[$bucketKey]['roots'][] = [ 'kind' => $kind, // "uploads" | "users" | "meta" 'path' => $real, ]; } $volumes = array_values($volumesMap); // Sort by usedPercent desc (heaviest first) usort($volumes, function (array $a, array $b): int { $pa = (float)($a['usedPercent'] ?? 0.0); $pb = (float)($b['usedPercent'] ?? 0.0); if ($pa === $pb) { return 0; } return ($pa < $pb) ? 1 : -1; }); // Backwards-compat: root filesystem metrics based on the volume // that contains UPLOAD_DIR (if we can detect it). $fsTotalBytes = null; $fsFreeBytes = null; $fsUsedBytes = null; $fsUsedPct = null; if ($uploadReal && !empty($volumes)) { foreach ($volumes as $vol) { foreach ($vol['roots'] as $root) { if (!isset($root['path'])) continue; if ((string)$root['path'] === (string)$uploadReal) { $fsTotalBytes = (int)$vol['totalBytes']; $fsFreeBytes = (int)$vol['freeBytes']; $fsUsedBytes = (int)$vol['usedBytes']; $fsUsedPct = (float)$vol['usedPercent']; break 2; } } } } // Top N non-root folders by bytes (from snapshot) $candidates = array_filter($folders, function (array $f): bool { return isset($f['key']) && $f['key'] !== 'root'; }); usort($candidates, function (array $a, array $b): int { $ba = (int)($a['bytes'] ?? 0); $bb = (int)($b['bytes'] ?? 0); if ($ba === $bb) { return strcmp((string)$a['key'], (string)$b['key']); } return ($ba < $bb) ? 1 : -1; }); if ($maxTopFolders > 0 && count($candidates) > $maxTopFolders) { $candidates = array_slice($candidates, 0, $maxTopFolders); } $topFolders = []; foreach ($candidates as $f) { $bytes = (int)($f['bytes'] ?? 0); $pct = ($rootBytes > 0) ? ($bytes * 100.0 / $rootBytes) : 0.0; $topFolders[] = [ 'folder' => (string)$f['key'], 'name' => (string)$f['name'], 'bytes' => $bytes, 'files' => (int)($f['files'] ?? 0), 'dirs' => (int)($f['dirs'] ?? 0), 'latest_mtime' => (int)($f['latest_mtime'] ?? 0), 'percentOfTotal' => $pct, ]; } // totalFolders: prefer snapshot["root_folders"], but fall back to counting $totalFolders = isset($snapshot['root_folders']) ? (int)$snapshot['root_folders'] : max(0, count($folders) - 1); $out = [ 'ok' => true, 'generatedAt' => (int)($snapshot['generated_at'] ?? 0), 'scanSeconds' => (float)($snapshot['scan_seconds'] ?? 0.0), 'totalBytes' => $rootBytes, 'totalFiles' => (int)($snapshot['root_files'] ?? 0), 'totalFolders' => $totalFolders, 'topFolders' => $topFolders, // original fields (for single-root view) 'uploadRoot' => $uploadReal, 'fsTotalBytes' => $fsTotalBytes, 'fsFreeBytes' => $fsFreeBytes, 'fsUsedBytes' => $fsUsedBytes, 'fsUsedPercent' => $fsUsedPct, // new grouped volumes: each with total/free/used and roots[] 'volumes' => $volumes, ]; if ($maxTopFilesPreview > 0) { $files = is_array($snapshot['files'] ?? null) ? $snapshot['files'] : []; if (count($files) > $maxTopFilesPreview) { $files = array_slice($files, 0, $maxTopFilesPreview); } $out['topFiles'] = $files; } return $out; } /** * Return direct children (folders + files) of a given folder key. * * @param string $folderKey * @return array */ public static function getChildren(string $folderKey): array { $folderKey = ($folderKey === '' || $folderKey === '/') ? 'root' : $folderKey; $snapshot = self::loadSnapshot(); if ($snapshot === null) { return [ 'ok' => false, 'error' => 'no_snapshot', ]; } $rootBytes = (int)($snapshot['root_bytes'] ?? 0); $folders = is_array($snapshot['folders'] ?? null) ? $snapshot['folders'] : []; $files = is_array($snapshot['files'] ?? null) ? $snapshot['files'] : []; // Index folders by key $folderByKey = []; foreach ($folders as $f) { if (!isset($f['key'])) continue; $folderByKey[(string)$f['key']] = $f; } if (!isset($folderByKey[$folderKey])) { return [ 'ok' => false, 'error' => 'folder_not_found', ]; } $childrenFolders = []; foreach ($folders as $f) { if (!isset($f['parent']) || !isset($f['key'])) continue; if ((string)$f['parent'] === $folderKey) { $bytes = (int)($f['bytes'] ?? 0); $pct = ($rootBytes > 0) ? ($bytes * 100.0 / $rootBytes) : 0.0; $childrenFolders[] = [ 'type' => 'folder', 'folder' => (string)$f['key'], 'name' => (string)$f['name'], 'bytes' => $bytes, 'files' => (int)($f['files'] ?? 0), 'dirs' => (int)($f['dirs'] ?? 0), 'latest_mtime' => (int)($f['latest_mtime'] ?? 0), 'percentOfTotal' => $pct, ]; } } $childrenFiles = []; foreach ($files as $file) { if (!isset($file['folder']) || !isset($file['name'])) continue; if ((string)$file['folder'] !== $folderKey) continue; $bytes = (int)($file['bytes'] ?? 0); $pct = ($rootBytes > 0) ? ($bytes * 100.0 / $rootBytes) : 0.0; $childrenFiles[] = [ 'type' => 'file', 'folder' => (string)$file['folder'], 'name' => (string)$file['name'], 'path' => (string)($file['path'] ?? $file['name']), 'bytes' => $bytes, 'mtime' => (int)($file['mtime'] ?? 0), 'percentOfTotal' => $pct, ]; } // Sort children: folders first (by bytes desc), then files (by bytes desc) usort($childrenFolders, function (array $a, array $b): int { $ba = (int)($a['bytes'] ?? 0); $bb = (int)($b['bytes'] ?? 0); if ($ba === $bb) { return strcmp((string)$a['name'], (string)$b['name']); } return ($ba < $bb) ? 1 : -1; }); usort($childrenFiles, function (array $a, array $b): int { $ba = (int)($a['bytes'] ?? 0); $bb = (int)($b['bytes'] ?? 0); if ($ba === $bb) { return strcmp((string)$a['name'], (string)$b['name']); } return ($ba < $bb) ? 1 : -1; }); return [ 'ok' => true, 'folder' => $folderKey, 'folders' => $childrenFolders, 'files' => $childrenFiles, ]; } /** * Return the global Top N files by size from the snapshot. * * @param int $limit * @return array */ public static function getTopFiles(int $limit = 100): array { $snapshot = self::loadSnapshot(); if ($snapshot === null) { return [ 'ok' => false, 'error' => 'no_snapshot', ]; } $rootBytes = (int)($snapshot['root_bytes'] ?? 0); $files = is_array($snapshot['files'] ?? null) ? $snapshot['files'] : []; if ($limit > 0 && count($files) > $limit) { $files = array_slice($files, 0, $limit); } $out = []; foreach ($files as $file) { $bytes = (int)($file['bytes'] ?? 0); $pct = ($rootBytes > 0) ? ($bytes * 100.0 / $rootBytes) : 0.0; $out[] = [ 'folder' => (string)($file['folder'] ?? 'root'), 'name' => (string)($file['name'] ?? ''), 'path' => (string)($file['path'] ?? ($file['name'] ?? '')), 'bytes' => $bytes, 'mtime' => (int)($file['mtime'] ?? 0), 'percentOfTotal' => $pct, ]; } return [ 'ok' => true, 'files' => $out, ]; } /** * Helper: derive the parent folder key ("root" -> null, "foo/bar" -> "foo"). */ private static function parentKeyOf(string $key): ?string { if ($key === 'root' || $key === '') { return null; } $key = trim($key, '/'); if ($key === '') return null; $pos = strrpos($key, '/'); if ($pos === false) { return 'root'; } $parent = substr($key, 0, $pos); return ($parent === '' ? 'root' : $parent); } /** * Helper: basename of a folder key. "root" -> "root", "foo/bar" -> "bar". */ private static function basenameKey(?string $key): string { if ($key === null || $key === '' || $key === 'root') { return 'root'; } $key = trim($key, '/'); $pos = strrpos($key, '/'); if ($pos === false) { return $key; } return substr($key, $pos + 1); } /** * Helper: approximate depth of a folder key (root->0, "foo"->1, "foo/bar"->2, etc.) */ private static function depthOf(string $key): int { if ($key === '' || $key === 'root') return 0; return substr_count(trim($key, '/'), '/') + 1; } }