release(v2.2.0): add storage explorer + disk usage scanner

This commit is contained in:
Ryan
2025-11-28 19:04:00 -05:00
committed by GitHub
parent 47b4cc4489
commit fe3a58924b
19 changed files with 3428 additions and 603 deletions

View File

@@ -1,5 +1,42 @@
# Changelog
## Changes 11/28/2025 (v2.2.0)
release(v2.2.0): add storage explorer + disk usage scanner
- New **Storage / Disk Usage** admin section with snapshot-based totals and "Top folders by size".
- Disk usage CLI scanner (`src/cli/disk_usage_scan.php`) and background rescan endpoint.
- New **Storage Explorer** (drilldown, top files view, deep-delete actions) available in FileRise Pro v1.2.0.
- Non-Pro installsshow a blurred preview of the explorer with upgrade prompts.
Features
- Add new "Storage / Disk Usage" section to the Admin Panel with a summary card and "Top folders by size" table.
- Introduce CLI disk usage scanner (src/cli/disk_usage_scan.php) that walks UPLOAD_DIR, applies FS::IGNORE()/SKIP(), and persists a structured snapshot to META_DIR/disk_usage.json.
- Add /api/admin/diskUsageSummary.php and /api/admin/diskUsageTriggerScan.php endpoints to expose the snapshot and trigger background rescans from the UI.
- Wire the new storage section into adminPanel.js with a Rescan button that launches the CLI worker and polls for a fresh snapshot.
Improvements
- Storage summary now shows total files, folders, scan duration, and last scan time, plus grouped volume usage across Uploads / Users / Metadata when available.
- "Top folders by size" table supports a Pro-only "show more" interaction, but still provides a clean preview in the core edition.
- Slight spacing / layout tweaks so the Storage card doesnt sit flush against the Admin Panel header.
Pro integration
- Keep the full ncdu-style "Storage explorer" (per-folder drilldown + global Top files, deep delete toggle, size filters, etc.) behind FR_PRO_ACTIVE via /api/pro/diskUsageChildren.php and /api/pro/diskUsageTopFiles.php.
- Pro-only delete-from-explorer actions are exposed via /api/pro/diskUsageDeleteFilePermanent.php and /api/pro/diskUsageDeleteFolderRecursive.php, reusing FileModel and FolderModel admin helpers.
- Non-Pro instances still see the explorer teaser, but the table body is blurred and padded with "Pro" badges, clearly advertising the upgrade path without exposing the Pro internals.
DX / internals
- Centralize disk usage logic in DiskUsageModel: snapshot builder, summary (including volumes), per-folder children view, and global Top N file listing.
- Ensure adminStorage.js is idempotent and safe to re-init when the Admin Panel is reopened (guards on data-* flags, re-wires only once).
- Add robust PHP-CLI discovery and log output for the disk usage worker, mirroring the existing zip worker pattern.
---
## Changes 11/27/2025 (v2.1.0)
🦃🍂 Happy Thanksgiving. 🥧🍁🍽️

View File

@@ -0,0 +1,41 @@
<?php
// public/api/admin/diskUsageSummary.php
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/models/DiskUsageModel.php';
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
header('Content-Type: application/json; charset=utf-8');
$authenticated = !empty($_SESSION['authenticated']);
$isAdmin = !empty($_SESSION['isAdmin']) || (!empty($_SESSION['admin']) && $_SESSION['admin'] === '1');
if (!$authenticated || !$isAdmin) {
http_response_code(401);
echo json_encode([
'ok' => false,
'error' => 'Unauthorized',
]);
exit;
}
// Optional tuning via query params
$topFolders = isset($_GET['topFolders']) ? max(1, (int)$_GET['topFolders']) : 5;
$topFiles = isset($_GET['topFiles']) ? max(0, (int)$_GET['topFiles']) : 0;
try {
$summary = DiskUsageModel::getSummary($topFolders, $topFiles);
http_response_code($summary['ok'] ? 200 : 404);
echo json_encode($summary, JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'ok' => false,
'error' => 'internal_error',
'message' => $e->getMessage(),
]);
}

View File

@@ -0,0 +1,102 @@
<?php
// public/api/admin/diskUsageTriggerScan.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/models/DiskUsageModel.php';
// Basic auth / admin check
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
$username = (string)($_SESSION['username'] ?? '');
$isAdmin = !empty($_SESSION['isAdmin']) || (!empty($_SESSION['admin']) && $_SESSION['admin'] === '1');
if ($username === '' || !$isAdmin) {
http_response_code(403);
echo json_encode([
'ok' => false,
'error' => 'Forbidden',
]);
return;
}
// Release session lock early so the scanner/other requests aren't blocked
@session_write_close();
// NOTE: previously this endpoint was Pro-only. Now it works on all instances.
// Pro-only gate removed so free FileRise can also use the Rescan button.
/*
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
http_response_code(403);
echo json_encode([
'ok' => false,
'error' => 'FileRise Pro is not active on this instance.',
]);
return;
}
*/
try {
$worker = realpath(PROJECT_ROOT . '/src/cli/disk_usage_scan.php');
if (!$worker || !is_file($worker)) {
throw new RuntimeException('disk_usage_scan.php not found.');
}
// Find a PHP CLI binary that actually works (same idea as zip_worker)
$candidates = array_values(array_filter([
PHP_BINARY ?: null,
'/usr/local/bin/php',
'/usr/bin/php',
'/bin/php',
]));
$php = null;
foreach ($candidates as $bin) {
if (!$bin) {
continue;
}
$rc = 1;
@exec(escapeshellcmd($bin) . ' -v >/dev/null 2>&1', $out, $rc);
if ($rc === 0) {
$php = $bin;
break;
}
}
if (!$php) {
throw new RuntimeException('No working php CLI found.');
}
$meta = rtrim((string)META_DIR, '/\\');
$logDir = $meta . DIRECTORY_SEPARATOR . 'logs';
@mkdir($logDir, 0775, true);
$logFile = $logDir . DIRECTORY_SEPARATOR . 'disk_usage_scan.log';
// nohup php disk_usage_scan.php >> log 2>&1 & echo $!
$cmdStr =
'nohup ' . escapeshellcmd($php) . ' ' . escapeshellarg($worker) .
' >> ' . escapeshellarg($logFile) . ' 2>&1 & echo $!';
$pid = @shell_exec('/bin/sh -c ' . escapeshellarg($cmdStr));
$pid = is_string($pid) ? (int)trim($pid) : 0;
http_response_code(200);
echo json_encode([
'ok' => true,
'pid' => $pid > 0 ? $pid : null,
'message' => 'Disk usage scan started in the background.',
'logFile' => $logFile,
], JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'ok' => false,
'error' => 'internal_error',
'message' => $e->getMessage(),
]);
}

View File

@@ -0,0 +1,53 @@
<?php
// public/api/pro/diskUsageChildren.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../config/config.php';
// Basic auth / admin check
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
$username = (string)($_SESSION['username'] ?? '');
$isAdmin = !empty($_SESSION['isAdmin']) || (!empty($_SESSION['admin']) && $_SESSION['admin'] === '1');
if ($username === '' || !$isAdmin) {
http_response_code(403);
echo json_encode([
'ok' => false,
'error' => 'Forbidden',
]);
return;
}
// Release session lock to avoid blocking parallel requests
@session_write_close();
// Pro-only gate: require Pro active AND ProDiskUsage class available
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !class_exists('ProDiskUsage')) {
http_response_code(403);
echo json_encode([
'ok' => false,
'error' => 'FileRise Pro is not active on this instance.',
]);
return;
}
$folderKey = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
try {
/** @var array $result */
$result = ProDiskUsage::getChildren($folderKey);
http_response_code(!empty($result['ok']) ? 200 : 404);
echo json_encode($result, JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'ok' => false,
'error' => 'internal_error',
'message' => $e->getMessage(),
]);
}

View File

@@ -0,0 +1,55 @@
<?php
// public/api/pro/diskUsageDeleteFilePermanent.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
require_once PROJECT_ROOT . '/src/models/FileModel.php';
// Pro-only gate: make sure Pro is really active
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
http_response_code(403);
echo json_encode(['ok' => false, 'error' => 'FileRise Pro is not active on this instance.']);
return;
}
try {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
return;
}
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
AdminController::requireAuth();
AdminController::requireAdmin();
AdminController::requireCsrf();
$raw = file_get_contents('php://input');
$body = json_decode($raw, true);
if (!is_array($body) || empty($body['name'])) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Invalid input']);
return;
}
$folder = isset($body['folder']) ? (string)$body['folder'] : 'root';
$folder = $folder === '' ? 'root' : trim($folder, "/\\ ");
$name = (string)$body['name'];
$res = FileModel::deleteFilesPermanent($folder, [$name]);
if (!empty($res['error'])) {
echo json_encode(['ok' => false, 'error' => $res['error']]);
} else {
echo json_encode(['ok' => true, 'success' => $res['success'] ?? 'File deleted.']);
}
} catch (Throwable $e) {
error_log('diskUsageDeleteFilePermanent error: '.$e->getMessage());
http_response_code(500);
echo json_encode(['ok' => false, 'error' => 'Internal error']);
}

View File

@@ -0,0 +1,60 @@
<?php
// public/api/pro/diskUsageDeleteFolderRecursive.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
// Pro-only gate
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
http_response_code(403);
echo json_encode(['ok' => false, 'error' => 'FileRise Pro is not active on this instance.']);
return;
}
try {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
return;
}
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
AdminController::requireAuth();
AdminController::requireAdmin();
AdminController::requireCsrf();
$raw = file_get_contents('php://input');
$body = json_decode($raw, true);
if (!is_array($body) || !isset($body['folder'])) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Invalid input']);
return;
}
$folder = (string)$body['folder'];
$folder = $folder === '' ? 'root' : trim($folder, "/\\ ");
if (strtolower($folder) === 'root') {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Cannot deep delete root folder.']);
return;
}
$res = FolderModel::deleteFolderRecursiveAdmin($folder);
if (!empty($res['error'])) {
echo json_encode(['ok' => false, 'error' => $res['error']]);
} else {
echo json_encode(['ok' => true, 'success' => $res['success'] ?? 'Folder deleted.']);
}
} catch (Throwable $e) {
error_log('diskUsageDeleteFolderRecursive error: '.$e->getMessage());
http_response_code(500);
echo json_encode(['ok' => false, 'error' => 'Internal error']);
}

View File

@@ -0,0 +1,51 @@
<?php
// public/api/pro/diskUsageTopFiles.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../config/config.php';
// Basic auth / admin check
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
$username = (string)($_SESSION['username'] ?? '');
$isAdmin = !empty($_SESSION['isAdmin']) || (!empty($_SESSION['admin']) && $_SESSION['admin'] === '1');
if ($username === '' || !$isAdmin) {
http_response_code(403);
echo json_encode([
'ok' => false,
'error' => 'Forbidden',
]);
return;
}
@session_write_close();
// Pro-only gate: require Pro active AND ProDiskUsage class
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !class_exists('ProDiskUsage')) {
http_response_code(403);
echo json_encode([
'ok' => false,
'error' => 'FileRise Pro is not active on this instance.',
]);
return;
}
$limit = isset($_GET['limit']) ? max(1, (int)$_GET['limit']) : 100;
try {
$result = ProDiskUsage::getTopFiles($limit);
http_response_code(!empty($result['ok']) ? 200 : 404);
echo json_encode($result, JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'ok' => false,
'error' => 'internal_error',
'message' => $e->getMessage(),
]);
}

View File

@@ -312,11 +312,6 @@ body{letter-spacing: 0.2px;
.dark-mode .folder-help-tooltip{background-color: #333 !important;
color: #eee !important;
border: 1px solid #555 !important;}
#folderHelpBtn i.material-icons.folder-help-icon{-webkit-text-fill-color: orange !important;
color: inherit !important;
padding-right: 10px !important;}
.dark-mode #folderHelpBtn i.material-icons.folder-help-icon{-webkit-text-fill-color: #ffa500 !important;
padding-right: 10px !important;}
@media (max-width: 790px) {
.header-container{flex-wrap: wrap;
height: auto;}

View File

@@ -25,7 +25,7 @@
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
<!-- Fonts (ok to keep as real preloads) -->
<!-- Fonts -->
<link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
<link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
@@ -211,10 +211,6 @@
<div id="folderManagementCard" class="card" style="width: 100%; position: relative;">
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
<span data-i18n-key="folder_navigation">Folder Navigation &amp; Management</span>
<button id="folderHelpBtn" class="btn btn-link" data-i18n-title="folder_help"
style="padding: 0; border: none; background: none;">
<i class="material-icons folder-help-icon" style="font-size: 24px;">info</i>
</button>
</div>
<div class="card-body custom-folder-card-body">
<div class="form-group d-flex align-items-top" style="padding-top:0; margin-bottom:0;">
@@ -297,27 +293,6 @@
</div>
</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);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>
</div>
</div>

File diff suppressed because it is too large Load Diff

1538
public/js/adminStorage.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,27 @@ export function handleDeleteSelected(e) {
attachEnterKeyListener("deleteFilesModal", "confirmDeleteFiles");
}
const FILE_MODAL_IDS = [
'deleteFilesModal',
'downloadZipModal',
'downloadProgressModal',
'createFileModal',
'downloadFileModal',
'copyFilesModal',
'moveFilesModal',
'renameFileModal',
'createFolderModal', // if this exists in your HTML
];
function portalFileModalsToBody() {
FILE_MODAL_IDS.forEach(id => {
const el = document.getElementById(id);
if (el && el.parentNode !== document.body) {
document.body.appendChild(el);
}
});
}
// --- Upload modal "portal" support ---
let _uploadCardSentinel = null;
@@ -818,6 +839,7 @@ document.addEventListener("DOMContentLoaded", () => {
// Expose initFileActions so it can be called from fileManager.js
export function initFileActions() {
portalFileModalsToBody();
const deleteSelectedBtn = document.getElementById("deleteSelectedBtn");
if (deleteSelectedBtn) {
deleteSelectedBtn.replaceWith(deleteSelectedBtn.cloneNode(true));

View File

@@ -35,6 +35,8 @@ const translations = {
"tag_name": "Tag Name:",
"tag_color": "Tag Color:",
"save_tag": "Save Tag",
"no_tags_available": "No tags available",
"current_tags": "Current Tags",
"light_mode": "Light Mode",
"dark_mode": "Dark Mode",
"upload_instruction": "Drop files/folders here or click 'Choose files'",
@@ -338,7 +340,9 @@ const translations = {
"modified": "Modified",
"created": "Created",
"owner": "Owner",
"hide_header_zoom_controls": "Hide header zoom controls"
"hide_header_zoom_controls": "Hide header zoom controls",
"preview_not_available": "Preview is not available for this file type.",
"storage_pro_bundle_outdated": "Please upgrade to the latest FileRise Pro bundle to use the Storage explorer."
},
es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 KiB

View File

@@ -1,9 +1,10 @@
#!/usr/bin/env bash
# === Update FileRise to v2.0.2 (safe rsync) ===
# === Update FileRise to v2.1.0 (safe rsync, no composer on demo) ===
set -Eeuo pipefail
VER="v2.0.2"
VER="v2.1.0"
ASSET="FileRise-${VER}.zip" # matches GitHub release asset name
WEBROOT="/var/www"
TMP="/tmp/filerise-update"
@@ -35,6 +36,7 @@ STAGE_DIR="$(find "$TMP" -maxdepth 1 -type d -name 'FileRise*' ! -path "$TMP" |
# - keep public/.htaccess
# - keep data dirs and current config.php
# - DO NOT touch filerise-site / bundles / demo config
# - DO NOT touch vendor/ so Stripe + other libs stay intact on demo
rsync -a --delete \
--exclude='public/.htaccess' \
--exclude='uploads/***' \
@@ -43,6 +45,7 @@ rsync -a --delete \
--exclude='filerise-bundles/***' \
--exclude='filerise-config/***' \
--exclude='filerise-site/***' \
--exclude='vendor/***' \
--exclude='.github/***' \
--exclude='docker-compose.yml' \
"$STAGE_DIR"/ "$WEBROOT"/
@@ -50,23 +53,20 @@ rsync -a --delete \
# 4) Ownership (Ubuntu/Debian w/ Apache)
chown -R www-data:www-data "$WEBROOT"
# 5) Composer autoload optimization if composer is available
if command -v composer >/dev/null 2>&1; then
cd "$WEBROOT" || { echo "cd to $WEBROOT failed" >&2; exit 1; }
composer install --no-dev --optimize-autoloader
fi
# 5) Composer — still disabled on demo
# if command -v composer >/dev/null 2>&1; then
# cd "$WEBROOT" || { echo "cd to $WEBROOT failed" >&2; exit 1; }
# composer install --no-dev --optimize-autoloader
# fi
# 6) Force demo mode ON in config/config.php
CFG_FILE="$WEBROOT/config/config.php"
if [[ -f "$CFG_FILE" ]]; then
# Make a one-time backup of config.php before editing
cp "$CFG_FILE" "${CFG_FILE}.bak.$stamp" || true
# Flip FR_DEMO_MODE to true if it exists as false
sed -i "s/define('FR_DEMO_MODE',[[:space:]]*false);/define('FR_DEMO_MODE', true);/" "$CFG_FILE" || true
fi
# 7) Reload Apache (dont fail the whole script if reload isnt available)
systemctl reload apache2 2>/dev/null || true
echo "FileRise updated to ${VER} (code). Demo mode forced ON. Data, Pro bundles, and demo site preserved."
echo "FileRise updated to ${VER} (code). Demo mode forced ON. Data, Pro bundles, site, and vendor/ (Stripe) preserved."

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
// src/cli/disk_usage_scan.php
//
// Build or refresh the disk usage snapshot used by the Admin "Storage / Disk Usage" view.
require __DIR__ . '/../../config/config.php';
require __DIR__ . '/../../src/models/DiskUsageModel.php';
$start = microtime(true);
try {
$snapshot = DiskUsageModel::buildSnapshot();
$elapsed = microtime(true) - $start;
$bytes = (int)($snapshot['root_bytes'] ?? 0);
$files = (int)($snapshot['root_files'] ?? 0);
$human = function (int $b): string {
if ($b <= 0) return '0 B';
$units = ['B','KB','MB','GB','TB','PB'];
$i = (int)floor(log($b, 1024));
$i = max(0, min($i, count($units) - 1));
$val = $b / pow(1024, $i);
return sprintf('%.2f %s', $val, $units[$i]);
};
$msg = sprintf(
"Disk usage snapshot written to %s\nScanned %d files, total %s in %.2f seconds.\n",
DiskUsageModel::snapshotPath(),
$files,
$human($bytes),
$elapsed
);
fwrite(STDOUT, $msg);
exit(0);
} catch (Throwable $e) {
fwrite(STDERR, "Error building disk usage snapshot: " . $e->getMessage() . "\n");
exit(1);
}

View File

@@ -0,0 +1,723 @@
<?php
// src/models/DiskUsageModel.php
declare(strict_types=1);
require_once PROJECT_ROOT . '/config/config.php';
require_once PROJECT_ROOT . '/src/lib/FS.php';
/**
* DiskUsageModel
*
* Builds and reads a cached snapshot of disk usage under UPLOAD_DIR.
* Snapshot is stored as JSON under META_DIR . '/disk_usage.json'.
*
* Folder keys mirror the rest of FileRise:
* - "root" is the upload root
* - "foo/bar" are subfolders under UPLOAD_DIR
*
* We intentionally skip:
* - trash subtree
* - profile_pics subtree
* - dot-prefixed names
* - FS::IGNORE() entries like @eaDir, .DS_Store, etc.
*/
class DiskUsageModel
{
/** Where we persist the snapshot JSON. */
public const SNAPSHOT_BASENAME = 'disk_usage.json';
/** Maximum number of per-file records to keep (for Top N view). */
private const TOP_FILE_LIMIT = 1000;
/**
* Absolute path to the snapshot JSON file.
*/
public static function snapshotPath(): string
{
$meta = rtrim((string)META_DIR, '/\\');
return $meta . DIRECTORY_SEPARATOR . self::SNAPSHOT_BASENAME;
}
/**
* Build a fresh snapshot of disk usage under UPLOAD_DIR and write it to disk.
*
* Returns the structured snapshot array (same shape as stored JSON).
*
* @throws RuntimeException on configuration or IO errors.
*/
public static function buildSnapshot(): array
{
$start = microtime(true);
$root = realpath(UPLOAD_DIR);
if ($root === false || !is_dir($root)) {
throw new RuntimeException('Uploads directory is not configured correctly.');
}
$root = rtrim($root, DIRECTORY_SEPARATOR);
$IGNORE = FS::IGNORE();
$SKIP = FS::SKIP();
// Folder map: key => [
// '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;
}
}

View File

@@ -549,6 +549,67 @@ class FileModel {
];
}
public static function deleteFilesPermanent(string $folder, array $files): array
{
$errors = [];
$deleted = [];
list($uploadDir, $err) = self::resolveFolderPath($folder, false);
if ($err) return ['error' => $err];
$uploadDir = rtrim($uploadDir, '/\\') . DIRECTORY_SEPARATOR;
$safeFileNamePattern = REGEX_FILE_NAME;
foreach ($files as $fileName) {
$originalName = basename(trim((string)$fileName));
$basename = $originalName;
if ($basename === '') {
$errors[] = 'Empty file name.';
continue;
}
if (!preg_match($safeFileNamePattern, $basename)) {
$errors[] = "$basename has an invalid name.";
continue;
}
$filePath = $uploadDir . $basename;
if (file_exists($filePath)) {
if (!@unlink($filePath)) {
$errors[] = "Failed to delete {$basename}.";
continue;
}
}
$deleted[] = $basename;
// Remove from folder metadata if present
$metadataFile = self::getMetadataFilePath($folder);
if (file_exists($metadataFile)) {
$meta = json_decode(file_get_contents($metadataFile), true);
if (is_array($meta) && isset($meta[$basename])) {
unset($meta[$basename]);
@file_put_contents($metadataFile, json_encode($meta, JSON_PRETTY_PRINT), LOCK_EX);
}
}
}
if ($errors && !$deleted) {
return ['error' => implode('; ', $errors)];
}
if ($errors) {
return [
'error' => implode('; ', $errors),
'success' => 'Deleted: ' . implode(', ', $deleted),
];
}
return ['success' => 'Deleted: ' . implode(', ', $deleted)];
}
/**
* Creates a ZIP archive of the specified files from a given folder.
*

View File

@@ -483,6 +483,64 @@ class FolderModel
}
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.