Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b83facc97 | ||
|
|
3e473d57b4 | ||
|
|
f2ce43f18f | ||
|
|
a50fa30db2 | ||
|
|
d6631adc2d | ||
|
|
997e5067d3 | ||
|
|
1c0ac50048 | ||
|
|
8fc716387b | ||
|
|
fe3a58924b |
73
CHANGELOG.md
73
CHANGELOG.md
@@ -1,5 +1,78 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Changes 11/29/2025 (v2.2.3)
|
||||||
|
|
||||||
|
fix(preview): harden SVG handling and normalize mime type
|
||||||
|
release(v2.2.3): round gallery card corners in file grid
|
||||||
|
|
||||||
|
- Stop treating SVGs as inline-previewable images in file list and preview modal
|
||||||
|
- Show a clear “SVG preview disabled for security reasons” message instead
|
||||||
|
- Keep SVGs downloadable via /api/file/download.php with proper image/svg+xml MIME
|
||||||
|
- Add i18n key for svg_preview_disabled
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/29/2025 (v2.2.2)
|
||||||
|
|
||||||
|
release(v2.2.2): feat(folders): show inline folder stats & dates
|
||||||
|
|
||||||
|
- Extend FolderModel::countVisible() to track earliest and latest file mtimes
|
||||||
|
- Format folder created/modified timestamps via DATE_TIME_FORMAT on the backend
|
||||||
|
- Add a small folder stats cache in fileListView.js to reuse isEmpty.php responses
|
||||||
|
- Use shared fetchFolderStats() for both folder strip icons and inline folder rows
|
||||||
|
- Show per-folder item counts, total size, and created/modified dates in inline rows
|
||||||
|
- Make size parsing more robust by accepting multiple backend size keys (bytes/sizeBytes/size/totalBytes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/28/2025 (v2.2.1)
|
||||||
|
|
||||||
|
release(v2.2.1): fix(storage-explorer): DOM-safe rendering + docs for disk usage
|
||||||
|
|
||||||
|
- Refactor adminStorage breadcrumb builder to construct DOM nodes instead of using innerHTML.
|
||||||
|
- Rework Storage explorer folder view to render rows via createElement/textContent, avoiding DOM text reinterpreted as HTML.
|
||||||
|
- Keep deep-delete and pagination behavior unchanged while tightening up XSS/CodeQL concerns.
|
||||||
|
- Update README feature list to mention disk usage summary and Pro storage explorer (ncdu-style) alongside user groups and client portals.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 doesn’t 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)
|
## Changes 11/27/2025 (v2.1.0)
|
||||||
|
|
||||||
🦃🍂 Happy Thanksgiving. 🥧🍁🍽️
|
🦃🍂 Happy Thanksgiving. 🥧🍁🍽️
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ Drag & drop uploads, ACL-aware sharing, OnlyOffice integration, and a clean UI
|
|||||||
- 🌳 **Scales to huge trees** – Tested with **100k+ folders** in the sidebar tree.
|
- 🌳 **Scales to huge trees** – Tested with **100k+ folders** in the sidebar tree.
|
||||||
- 🧩 **ONLYOFFICE support (optional)** – Edit DOCX/XLSX/PPTX using your own Document Server.
|
- 🧩 **ONLYOFFICE support (optional)** – Edit DOCX/XLSX/PPTX using your own Document Server.
|
||||||
- 🌍 **WebDAV** – Mount FileRise as a drive from macOS, Windows, Linux, or Cyberduck/WinSCP.
|
- 🌍 **WebDAV** – Mount FileRise as a drive from macOS, Windows, Linux, or Cyberduck/WinSCP.
|
||||||
|
- 📊 **Storage / disk usage summary** – CLI scanner with snapshots, total usage, and per-volume breakdowns in the admin panel.
|
||||||
- 🎨 **Polished UI** – Dark/light mode, responsive layout, in-browser previews & code editor.
|
- 🎨 **Polished UI** – Dark/light mode, responsive layout, in-browser previews & code editor.
|
||||||
- 🔑 **Login + SSO** – Local users, TOTP 2FA, and OIDC (Auth0 / Authentik / Keycloak / etc.).
|
- 🔑 **Login + SSO** – Local users, TOTP 2FA, and OIDC (Auth0 / Authentik / Keycloak / etc.).
|
||||||
- 👥 **User groups & client portals (Pro)** – Group-based ACLs and brandable client upload portals.
|
- 👥 **Pro: user groups, client portals & storage explorer** – Group-based ACLs, brandable client upload portals, and an ncdu-style explorer to drill into folders, largest files, and clean up storage inline.
|
||||||
|
|
||||||
Full list of features available at [Full Feature Wiki](https://github.com/error311/FileRise/wiki/Features)
|
Full list of features available at [Full Feature Wiki](https://github.com/error311/FileRise/wiki/Features)
|
||||||
|
|
||||||
|
|||||||
41
public/api/admin/diskUsageSummary.php
Normal file
41
public/api/admin/diskUsageSummary.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
102
public/api/admin/diskUsageTriggerScan.php
Normal file
102
public/api/admin/diskUsageTriggerScan.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
53
public/api/pro/diskUsageChildren.php
Normal file
53
public/api/pro/diskUsageChildren.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
55
public/api/pro/diskUsageDeleteFilePermanent.php
Normal file
55
public/api/pro/diskUsageDeleteFilePermanent.php
Normal 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']);
|
||||||
|
}
|
||||||
60
public/api/pro/diskUsageDeleteFolderRecursive.php
Normal file
60
public/api/pro/diskUsageDeleteFolderRecursive.php
Normal 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']);
|
||||||
|
}
|
||||||
51
public/api/pro/diskUsageTopFiles.php
Normal file
51
public/api/pro/diskUsageTopFiles.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -312,11 +312,6 @@ body{letter-spacing: 0.2px;
|
|||||||
.dark-mode .folder-help-tooltip{background-color: #333 !important;
|
.dark-mode .folder-help-tooltip{background-color: #333 !important;
|
||||||
color: #eee !important;
|
color: #eee !important;
|
||||||
border: 1px solid #555 !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) {
|
@media (max-width: 790px) {
|
||||||
.header-container{flex-wrap: wrap;
|
.header-container{flex-wrap: wrap;
|
||||||
height: auto;}
|
height: auto;}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
|
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
|
||||||
<link rel="stylesheet" href="/css/vendor/roboto.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/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>
|
<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 id="folderManagementCard" class="card" style="width: 100%; position: relative;">
|
||||||
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
|
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
<span data-i18n-key="folder_navigation">Folder Navigation & Management</span>
|
<span data-i18n-key="folder_navigation">Folder Navigation & 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>
|
||||||
<div class="card-body custom-folder-card-body">
|
<div class="card-body custom-folder-card-body">
|
||||||
<div class="form-group d-flex align-items-top" style="padding-top:0; margin-bottom:0;">
|
<div class="form-group d-flex align-items-top" style="padding-top:0; margin-bottom:0;">
|
||||||
@@ -297,27 +293,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 can’t 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
1684
public/js/adminStorage.js
Normal file
1684
public/js/adminStorage.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -179,9 +179,22 @@ export function buildFileTableRow(file, folderPath) {
|
|||||||
const safeUploader = escapeHTML(file.uploader || "Unknown");
|
const safeUploader = escapeHTML(file.uploader || "Unknown");
|
||||||
|
|
||||||
let previewButton = "";
|
let previewButton = "";
|
||||||
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tif|tiff|eps|heic|pdf|mp4|webm|mov|mp3|wav|m4a|ogg|flac|aac|wma|opus|mkv|ogv)$/i.test(file.name)) {
|
|
||||||
|
const isSvg = /\.svg$/i.test(file.name);
|
||||||
|
|
||||||
|
// IMPORTANT: do NOT treat SVG as previewable
|
||||||
|
if (
|
||||||
|
!isSvg &&
|
||||||
|
/\.(jpg|jpeg|png|gif|bmp|webp|ico|tif|tiff|eps|heic|pdf|mp4|webm|mov|mp3|wav|m4a|ogg|flac|aac|wma|opus|mkv|ogv)$/i
|
||||||
|
.test(file.name)
|
||||||
|
) {
|
||||||
let previewIcon = "";
|
let previewIcon = "";
|
||||||
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tif|tiff|eps|heic)$/i.test(file.name)) {
|
|
||||||
|
// images (SVG explicitly excluded)
|
||||||
|
if (
|
||||||
|
/\.(jpg|jpeg|png|gif|bmp|webp|ico|tif|tiff|eps|heic)$/i
|
||||||
|
.test(file.name)
|
||||||
|
) {
|
||||||
previewIcon = `<i class="material-icons">image</i>`;
|
previewIcon = `<i class="material-icons">image</i>`;
|
||||||
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(file.name)) {
|
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(file.name)) {
|
||||||
previewIcon = `<i class="material-icons">videocam</i>`;
|
previewIcon = `<i class="material-icons">videocam</i>`;
|
||||||
@@ -190,14 +203,16 @@ export function buildFileTableRow(file, folderPath) {
|
|||||||
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
|
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
|
||||||
previewIcon = `<i class="material-icons">audiotrack</i>`;
|
previewIcon = `<i class="material-icons">audiotrack</i>`;
|
||||||
}
|
}
|
||||||
previewButton = `<button
|
|
||||||
type="button"
|
previewButton = `
|
||||||
class="btn btn-sm btn-info preview-btn"
|
<button
|
||||||
data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
|
type="button"
|
||||||
data-preview-name="${safeFileName}"
|
class="btn btn-sm btn-info preview-btn"
|
||||||
title="${t('preview')}">
|
data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
|
||||||
${previewIcon}
|
data-preview-name="${safeFileName}"
|
||||||
</button>`;
|
title="${t('preview')}">
|
||||||
|
${previewIcon}
|
||||||
|
</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -242,13 +257,13 @@ export function buildFileTableRow(file, folderPath) {
|
|||||||
<i class="material-icons">drive_file_rename_outline</i>
|
<i class="material-icons">drive_file_rename_outline</i>
|
||||||
</button>
|
</button>
|
||||||
<!-- share -->
|
<!-- share -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-secondary btn-sm share-btn ms-1"
|
class="btn btn-secondary btn-sm share-btn ms-1"
|
||||||
data-file="${safeFileName}"
|
data-file="${safeFileName}"
|
||||||
title="${t('share')}">
|
title="${t('share')}">
|
||||||
<i class="material-icons">share</i>
|
<i class="material-icons">share</i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -20,6 +20,27 @@ export function handleDeleteSelected(e) {
|
|||||||
attachEnterKeyListener("deleteFilesModal", "confirmDeleteFiles");
|
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 ---
|
// --- Upload modal "portal" support ---
|
||||||
let _uploadCardSentinel = null;
|
let _uploadCardSentinel = null;
|
||||||
@@ -818,6 +839,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
// Expose initFileActions so it can be called from fileManager.js
|
// Expose initFileActions so it can be called from fileManager.js
|
||||||
export function initFileActions() {
|
export function initFileActions() {
|
||||||
|
portalFileModalsToBody();
|
||||||
const deleteSelectedBtn = document.getElementById("deleteSelectedBtn");
|
const deleteSelectedBtn = document.getElementById("deleteSelectedBtn");
|
||||||
if (deleteSelectedBtn) {
|
if (deleteSelectedBtn) {
|
||||||
deleteSelectedBtn.replaceWith(deleteSelectedBtn.cloneNode(true));
|
deleteSelectedBtn.replaceWith(deleteSelectedBtn.cloneNode(true));
|
||||||
|
|||||||
@@ -295,6 +295,27 @@ try {
|
|||||||
// Global flag for advanced search mode.
|
// Global flag for advanced search mode.
|
||||||
window.advancedSearchEnabled = false;
|
window.advancedSearchEnabled = false;
|
||||||
|
|
||||||
|
// --- Folder stats cache (for isEmpty.php) ---
|
||||||
|
const _folderStatsCache = new Map();
|
||||||
|
|
||||||
|
function fetchFolderStats(folder) {
|
||||||
|
if (!folder) return Promise.resolve(null);
|
||||||
|
|
||||||
|
if (_folderStatsCache.has(folder)) {
|
||||||
|
return _folderStatsCache.get(folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(folder)}&t=${Date.now()}`;
|
||||||
|
const p = _fetchJSONWithTimeout(url, 2500)
|
||||||
|
.catch(() => ({ folders: 0, files: 0 }))
|
||||||
|
.finally(() => {
|
||||||
|
// keep the resolved value; the Promise itself stays in the map
|
||||||
|
});
|
||||||
|
|
||||||
|
_folderStatsCache.set(folder, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===========================================================
|
/* ===========================================================
|
||||||
SECURITY: build file URLs only via the API (no /uploads)
|
SECURITY: build file URLs only via the API (no /uploads)
|
||||||
=========================================================== */
|
=========================================================== */
|
||||||
@@ -428,19 +449,19 @@ function attachStripIconAsync(hostEl, fullPath, size = 28) {
|
|||||||
// make sure this brand-new SVG is sized correctly
|
// make sure this brand-new SVG is sized correctly
|
||||||
try { syncFolderIconSizeToRowHeight(); } catch {}
|
try { syncFolderIconSizeToRowHeight(); } catch {}
|
||||||
|
|
||||||
const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(fullPath)}&t=${Date.now()}`;
|
fetchFolderStats(fullPath)
|
||||||
_fetchJSONWithTimeout(url, 2500)
|
.then(stats => {
|
||||||
.then(({ folders = 0, files = 0 }) => {
|
if (!stats) return;
|
||||||
if ((folders + files) > 0 && iconSpan.dataset.kind !== 'paper') {
|
const folders = Number.isFinite(stats.folders) ? stats.folders : 0;
|
||||||
// 2) swap to "paper" icon
|
const files = Number.isFinite(stats.files) ? stats.files : 0;
|
||||||
iconSpan.dataset.kind = 'paper';
|
|
||||||
iconSpan.innerHTML = folderSVG('paper');
|
|
||||||
|
|
||||||
// re-apply sizing to this new SVG too
|
if ((folders + files) > 0 && iconSpan.dataset.kind !== 'paper') {
|
||||||
try { syncFolderIconSizeToRowHeight(); } catch {}
|
iconSpan.dataset.kind = 'paper';
|
||||||
}
|
iconSpan.innerHTML = folderSVG('paper');
|
||||||
})
|
try { syncFolderIconSizeToRowHeight(); } catch {}
|
||||||
.catch(() => { /* ignore */ });
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -----------------------------
|
/* -----------------------------
|
||||||
@@ -1156,6 +1177,19 @@ function injectInlineFolderRows(fileListContent, folder, pageSubfolders) {
|
|||||||
);
|
);
|
||||||
if (actionsIdx < 0) actionsIdx = -1;
|
if (actionsIdx < 0) actionsIdx = -1;
|
||||||
|
|
||||||
|
// NEW: created / modified column indices (uploaded = created in your header)
|
||||||
|
let createdIdx = headerCells.findIndex(th =>
|
||||||
|
(th.dataset && (th.dataset.column === "uploaded" || th.dataset.column === "created")) ||
|
||||||
|
/\b(uploaded|created)\b/i.test((th.textContent || "").trim())
|
||||||
|
);
|
||||||
|
if (createdIdx < 0) createdIdx = -1;
|
||||||
|
|
||||||
|
let modifiedIdx = headerCells.findIndex(th =>
|
||||||
|
(th.dataset && th.dataset.column === "modified") ||
|
||||||
|
/\bmodified\b/i.test((th.textContent || "").trim())
|
||||||
|
);
|
||||||
|
if (modifiedIdx < 0) modifiedIdx = -1;
|
||||||
|
|
||||||
// Remove any previous folder rows
|
// Remove any previous folder rows
|
||||||
tbody.querySelectorAll("tr.folder-row").forEach(tr => tr.remove());
|
tbody.querySelectorAll("tr.folder-row").forEach(tr => tr.remove());
|
||||||
|
|
||||||
@@ -1356,19 +1390,32 @@ if (iconSpan) {
|
|||||||
iconSpan.style.marginTop = "0px"; // small down nudge
|
iconSpan.style.marginTop = "0px"; // small down nudge
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- FOLDER STATS + OWNER + CAPS (keep your existing code below here) -----
|
// ----- FOLDER STATS + OWNER + CAPS -----
|
||||||
const sizeCellIndex = (sizeIdx >= 0 && sizeIdx < tr.cells.length) ? sizeIdx : -1;
|
const sizeCellIndex = (sizeIdx >= 0 && sizeIdx < tr.cells.length) ? sizeIdx : -1;
|
||||||
const nameCellIndex = (nameIdx >= 0 && nameIdx < tr.cells.length) ? nameIdx : -1;
|
const nameCellIndex = (nameIdx >= 0 && nameIdx < tr.cells.length) ? nameIdx : -1;
|
||||||
|
const createdCellIndex = (createdIdx >= 0 && createdIdx < tr.cells.length) ? createdIdx : -1;
|
||||||
|
const modifiedCellIndex = (modifiedIdx >= 0 && modifiedIdx < tr.cells.length) ? modifiedIdx : -1;
|
||||||
|
|
||||||
const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(sf.full)}&t=${Date.now()}`;
|
fetchFolderStats(sf.full).then(stats => {
|
||||||
_fetchJSONWithTimeout(url, 2500).then(stats => {
|
|
||||||
if (!stats) return;
|
if (!stats) return;
|
||||||
|
|
||||||
const foldersCount = Number.isFinite(stats.folders) ? stats.folders : 0;
|
const foldersCount = Number.isFinite(stats.folders) ? stats.folders : 0;
|
||||||
const filesCount = Number.isFinite(stats.files) ? stats.files : 0;
|
const filesCount = Number.isFinite(stats.files) ? stats.files : 0;
|
||||||
const bytes = Number.isFinite(stats.bytes)
|
// Try multiple possible size keys so backend + JS can drift a bit
|
||||||
? stats.bytes
|
let bytes = null;
|
||||||
: (Number.isFinite(stats.sizeBytes) ? stats.sizeBytes : null);
|
const sizeCandidates = [
|
||||||
|
stats.bytes,
|
||||||
|
stats.sizeBytes,
|
||||||
|
stats.size,
|
||||||
|
stats.totalBytes
|
||||||
|
];
|
||||||
|
for (const v of sizeCandidates) {
|
||||||
|
const n = Number(v);
|
||||||
|
if (Number.isFinite(n) && n >= 0) {
|
||||||
|
bytes = n;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let pieces = [];
|
let pieces = [];
|
||||||
if (foldersCount) pieces.push(`${foldersCount} folder${foldersCount === 1 ? "" : "s"}`);
|
if (foldersCount) pieces.push(`${foldersCount} folder${foldersCount === 1 ? "" : "s"}`);
|
||||||
@@ -1395,6 +1442,26 @@ if (iconSpan) {
|
|||||||
sizeCell.title = `${countLabel}${bytes != null && bytes >= 0 ? " • " + sizeLabel : ""}`;
|
sizeCell.title = `${countLabel}${bytes != null && bytes >= 0 ? " • " + sizeLabel : ""}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (createdCellIndex >= 0) {
|
||||||
|
const createdCell = tr.cells[createdCellIndex];
|
||||||
|
if (createdCell) {
|
||||||
|
const txt = (stats && typeof stats.earliest_uploaded === 'string')
|
||||||
|
? stats.earliest_uploaded
|
||||||
|
: '';
|
||||||
|
createdCell.textContent = txt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modifiedCellIndex >= 0) {
|
||||||
|
const modCell = tr.cells[modifiedCellIndex];
|
||||||
|
if (modCell) {
|
||||||
|
const txt = (stats && typeof stats.latest_mtime === 'string')
|
||||||
|
? stats.latest_mtime
|
||||||
|
: '';
|
||||||
|
modCell.textContent = txt;
|
||||||
|
}
|
||||||
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
if (sizeCellIndex >= 0) {
|
if (sizeCellIndex >= 0) {
|
||||||
const sizeCell = tr.cells[sizeCellIndex];
|
const sizeCell = tr.cells[sizeCellIndex];
|
||||||
@@ -1887,7 +1954,7 @@ export function renderGalleryView(folder, container) {
|
|||||||
|
|
||||||
// thumbnail
|
// thumbnail
|
||||||
let thumbnail;
|
let thumbnail;
|
||||||
if (/\.(jpe?g|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) {
|
if (/\.(jpe?g|png|gif|bmp|webp|ico)$/i.test(file.name)) {
|
||||||
const cacheKey = previewURL; // include folder & file
|
const cacheKey = previewURL; // include folder & file
|
||||||
if (window.imageCache && window.imageCache[cacheKey]) {
|
if (window.imageCache && window.imageCache[cacheKey]) {
|
||||||
thumbnail = `<img
|
thumbnail = `<img
|
||||||
@@ -1931,7 +1998,7 @@ export function renderGalleryView(folder, container) {
|
|||||||
galleryHTML += `
|
galleryHTML += `
|
||||||
<div class="gallery-card"
|
<div class="gallery-card"
|
||||||
data-file-name="${escapeHTML(file.name)}"
|
data-file-name="${escapeHTML(file.name)}"
|
||||||
style="position:relative; border:1px solid #ccc; padding:5px; text-align:center;">
|
style="position:relative; border-radius: 12px; border:1px solid #ccc; padding:5px; text-align:center;">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
class="file-checkbox"
|
class="file-checkbox"
|
||||||
id="cb-${idSafe}"
|
id="cb-${idSafe}"
|
||||||
|
|||||||
@@ -120,7 +120,12 @@ export function openShareModal(file, folder) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------- Media modal viewer -------------------------------- */
|
/* -------------------------------- Media modal viewer -------------------------------- */
|
||||||
const IMG_RE = /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i;
|
// Images that are safe to inline in <img> tags:
|
||||||
|
const IMG_RE = /\.(jpg|jpeg|png|gif|bmp|webp|ico)$/i;
|
||||||
|
|
||||||
|
// SVG handled separately so we *don’t* inline it
|
||||||
|
const SVG_RE = /\.svg$/i;
|
||||||
|
|
||||||
const VID_RE = /\.(mp4|mkv|webm|mov|ogv)$/i;
|
const VID_RE = /\.(mp4|mkv|webm|mov|ogv)$/i;
|
||||||
const AUD_RE = /\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i;
|
const AUD_RE = /\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i;
|
||||||
const ARCH_RE = /\.(zip|rar|7z|gz|bz2|xz|tar)$/i;
|
const ARCH_RE = /\.(zip|rar|7z|gz|bz2|xz|tar)$/i;
|
||||||
@@ -422,11 +427,19 @@ export function previewFile(fileUrl, fileName) {
|
|||||||
const folder = window.currentFolder || 'root';
|
const folder = window.currentFolder || 'root';
|
||||||
const name = fileName;
|
const name = fileName;
|
||||||
const lower = (name || '').toLowerCase();
|
const lower = (name || '').toLowerCase();
|
||||||
|
const isSvg = SVG_RE.test(lower);
|
||||||
const isImage = IMG_RE.test(lower);
|
const isImage = IMG_RE.test(lower);
|
||||||
const isVideo = VID_RE.test(lower);
|
const isVideo = VID_RE.test(lower);
|
||||||
const isAudio = AUD_RE.test(lower);
|
const isAudio = AUD_RE.test(lower);
|
||||||
|
|
||||||
setTitle(overlay, name);
|
setTitle(overlay, name);
|
||||||
|
if (isSvg) {
|
||||||
|
container.textContent =
|
||||||
|
t("svg_preview_disabled") ||
|
||||||
|
"SVG preview is disabled for security. Use Download to view this file.";
|
||||||
|
overlay.style.display = "flex";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
/* -------------------- IMAGES -------------------- */
|
/* -------------------- IMAGES -------------------- */
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ const translations = {
|
|||||||
"tag_name": "Tag Name:",
|
"tag_name": "Tag Name:",
|
||||||
"tag_color": "Tag Color:",
|
"tag_color": "Tag Color:",
|
||||||
"save_tag": "Save Tag",
|
"save_tag": "Save Tag",
|
||||||
|
"no_tags_available": "No tags available",
|
||||||
|
"current_tags": "Current Tags",
|
||||||
"light_mode": "Light Mode",
|
"light_mode": "Light Mode",
|
||||||
"dark_mode": "Dark Mode",
|
"dark_mode": "Dark Mode",
|
||||||
"upload_instruction": "Drop files/folders here or click 'Choose files'",
|
"upload_instruction": "Drop files/folders here or click 'Choose files'",
|
||||||
@@ -338,7 +340,10 @@ const translations = {
|
|||||||
"modified": "Modified",
|
"modified": "Modified",
|
||||||
"created": "Created",
|
"created": "Created",
|
||||||
"owner": "Owner",
|
"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.",
|
||||||
|
"svg_preview_disabled": "SVG preview is disabled for now for security reasons."
|
||||||
},
|
},
|
||||||
es: {
|
es: {
|
||||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// generated by CI
|
// generated by CI
|
||||||
window.APP_VERSION = 'v2.1.0';
|
window.APP_VERSION = 'v2.2.3';
|
||||||
|
|||||||
BIN
resources/StorageDiskUsage.png
Normal file
BIN
resources/StorageDiskUsage.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 738 KiB |
@@ -1,9 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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
|
set -Eeuo pipefail
|
||||||
|
|
||||||
VER="v2.0.2"
|
VER="v2.1.0"
|
||||||
ASSET="FileRise-${VER}.zip" # matches GitHub release asset name
|
ASSET="FileRise-${VER}.zip" # matches GitHub release asset name
|
||||||
|
|
||||||
WEBROOT="/var/www"
|
WEBROOT="/var/www"
|
||||||
TMP="/tmp/filerise-update"
|
TMP="/tmp/filerise-update"
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ STAGE_DIR="$(find "$TMP" -maxdepth 1 -type d -name 'FileRise*' ! -path "$TMP" |
|
|||||||
# - keep public/.htaccess
|
# - keep public/.htaccess
|
||||||
# - keep data dirs and current config.php
|
# - keep data dirs and current config.php
|
||||||
# - DO NOT touch filerise-site / bundles / demo config
|
# - DO NOT touch filerise-site / bundles / demo config
|
||||||
|
# - DO NOT touch vendor/ so Stripe + other libs stay intact on demo
|
||||||
rsync -a --delete \
|
rsync -a --delete \
|
||||||
--exclude='public/.htaccess' \
|
--exclude='public/.htaccess' \
|
||||||
--exclude='uploads/***' \
|
--exclude='uploads/***' \
|
||||||
@@ -43,6 +45,7 @@ rsync -a --delete \
|
|||||||
--exclude='filerise-bundles/***' \
|
--exclude='filerise-bundles/***' \
|
||||||
--exclude='filerise-config/***' \
|
--exclude='filerise-config/***' \
|
||||||
--exclude='filerise-site/***' \
|
--exclude='filerise-site/***' \
|
||||||
|
--exclude='vendor/***' \
|
||||||
--exclude='.github/***' \
|
--exclude='.github/***' \
|
||||||
--exclude='docker-compose.yml' \
|
--exclude='docker-compose.yml' \
|
||||||
"$STAGE_DIR"/ "$WEBROOT"/
|
"$STAGE_DIR"/ "$WEBROOT"/
|
||||||
@@ -50,23 +53,20 @@ rsync -a --delete \
|
|||||||
# 4) Ownership (Ubuntu/Debian w/ Apache)
|
# 4) Ownership (Ubuntu/Debian w/ Apache)
|
||||||
chown -R www-data:www-data "$WEBROOT"
|
chown -R www-data:www-data "$WEBROOT"
|
||||||
|
|
||||||
# 5) Composer autoload optimization if composer is available
|
# 5) Composer — still disabled on demo
|
||||||
if command -v composer >/dev/null 2>&1; then
|
# if command -v composer >/dev/null 2>&1; then
|
||||||
cd "$WEBROOT" || { echo "cd to $WEBROOT failed" >&2; exit 1; }
|
# cd "$WEBROOT" || { echo "cd to $WEBROOT failed" >&2; exit 1; }
|
||||||
composer install --no-dev --optimize-autoloader
|
# composer install --no-dev --optimize-autoloader
|
||||||
fi
|
# fi
|
||||||
|
|
||||||
# 6) Force demo mode ON in config/config.php
|
# 6) Force demo mode ON in config/config.php
|
||||||
CFG_FILE="$WEBROOT/config/config.php"
|
CFG_FILE="$WEBROOT/config/config.php"
|
||||||
if [[ -f "$CFG_FILE" ]]; then
|
if [[ -f "$CFG_FILE" ]]; then
|
||||||
# Make a one-time backup of config.php before editing
|
|
||||||
cp "$CFG_FILE" "${CFG_FILE}.bak.$stamp" || true
|
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
|
sed -i "s/define('FR_DEMO_MODE',[[:space:]]*false);/define('FR_DEMO_MODE', true);/" "$CFG_FILE" || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 7) Reload Apache (don’t fail the whole script if reload isn’t available)
|
# 7) Reload Apache (don’t fail the whole script if reload isn’t available)
|
||||||
systemctl reload apache2 2>/dev/null || true
|
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."
|
||||||
42
src/cli/disk_usage_scan.php
Normal file
42
src/cli/disk_usage_scan.php
Normal 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);
|
||||||
|
}
|
||||||
723
src/models/DiskUsageModel.php
Normal file
723
src/models/DiskUsageModel.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -503,13 +503,13 @@ class FileModel {
|
|||||||
if (!preg_match(REGEX_FILE_NAME, $file)) {
|
if (!preg_match(REGEX_FILE_NAME, $file)) {
|
||||||
return ["error" => "Invalid file name."];
|
return ["error" => "Invalid file name."];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the real upload directory.
|
// Determine the real upload directory.
|
||||||
$uploadDirReal = realpath(UPLOAD_DIR);
|
$uploadDirReal = realpath(UPLOAD_DIR);
|
||||||
if ($uploadDirReal === false) {
|
if ($uploadDirReal === false) {
|
||||||
return ["error" => "Server misconfiguration."];
|
return ["error" => "Server misconfiguration."];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine directory based on folder.
|
// Determine directory based on folder.
|
||||||
if (strtolower($folder) === 'root' || trim($folder) === '') {
|
if (strtolower($folder) === 'root' || trim($folder) === '') {
|
||||||
$directory = $uploadDirReal;
|
$directory = $uploadDirReal;
|
||||||
@@ -524,11 +524,11 @@ class FileModel {
|
|||||||
return ["error" => "Invalid folder path."];
|
return ["error" => "Invalid folder path."];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the file path.
|
// Build the file path.
|
||||||
$filePath = $directory . DIRECTORY_SEPARATOR . $file;
|
$filePath = $directory . DIRECTORY_SEPARATOR . $file;
|
||||||
$realFilePath = realpath($filePath);
|
$realFilePath = realpath($filePath);
|
||||||
|
|
||||||
// Ensure the file exists and is within the allowed directory.
|
// Ensure the file exists and is within the allowed directory.
|
||||||
if ($realFilePath === false || strpos($realFilePath, $uploadDirReal) !== 0) {
|
if ($realFilePath === false || strpos($realFilePath, $uploadDirReal) !== 0) {
|
||||||
return ["error" => "Access forbidden."];
|
return ["error" => "Access forbidden."];
|
||||||
@@ -536,19 +536,86 @@ class FileModel {
|
|||||||
if (!file_exists($realFilePath)) {
|
if (!file_exists($realFilePath)) {
|
||||||
return ["error" => "File not found."];
|
return ["error" => "File not found."];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the MIME type with safe fallback.
|
// Get the MIME type with safe fallback.
|
||||||
$mimeType = function_exists('mime_content_type') ? mime_content_type($realFilePath) : null;
|
$mimeType = function_exists('mime_content_type') ? mime_content_type($realFilePath) : null;
|
||||||
if (!$mimeType) {
|
if (!$mimeType) {
|
||||||
$mimeType = 'application/octet-stream';
|
$mimeType = 'application/octet-stream';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OPTIONAL: normalize SVG MIME
|
||||||
|
$ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION));
|
||||||
|
if ($ext === 'svg') {
|
||||||
|
$mimeType = 'image/svg+xml';
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"filePath" => $realFilePath,
|
"filePath" => $realFilePath,
|
||||||
"mimeType" => $mimeType
|
"mimeType" => $mimeType
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
* Creates a ZIP archive of the specified files from a given folder.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -12,110 +12,135 @@ class FolderModel
|
|||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
|
|
||||||
public static function countVisible(string $folder, string $user, array $perms): array
|
public static function countVisible(string $folder, string $user, array $perms): array
|
||||||
{
|
{
|
||||||
$folder = ACL::normalizeFolder($folder);
|
$folder = ACL::normalizeFolder($folder);
|
||||||
|
|
||||||
// If the user can't view this folder at all, short-circuit (admin/read/read_own)
|
// If the user can't view this folder at all, short-circuit (admin/read/read_own)
|
||||||
$canViewFolder = ACL::isAdmin($perms)
|
$canViewFolder = ACL::isAdmin($perms)
|
||||||
|| ACL::canRead($user, $perms, $folder)
|
|| ACL::canRead($user, $perms, $folder)
|
||||||
|| ACL::canReadOwn($user, $perms, $folder);
|
|| ACL::canReadOwn($user, $perms, $folder);
|
||||||
if (!$canViewFolder) {
|
if (!$canViewFolder) {
|
||||||
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
|
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEW: distinguish full read vs own-only for this folder
|
// NEW: distinguish full read vs own-only for this folder
|
||||||
$hasFullRead = ACL::isAdmin($perms) || ACL::canRead($user, $perms, $folder);
|
$hasFullRead = ACL::isAdmin($perms) || ACL::canRead($user, $perms, $folder);
|
||||||
// if !$hasFullRead but $canViewFolder is true, they’re effectively "view own" only
|
// if !$hasFullRead but $canViewFolder is true, they’re effectively "view own" only
|
||||||
|
|
||||||
$base = realpath((string)UPLOAD_DIR);
|
$base = realpath((string)UPLOAD_DIR);
|
||||||
if ($base === false) {
|
if ($base === false) {
|
||||||
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
|
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve target dir + ACL-relative prefix
|
// Resolve target dir + ACL-relative prefix
|
||||||
if ($folder === 'root') {
|
if ($folder === 'root') {
|
||||||
$dir = $base;
|
$dir = $base;
|
||||||
$relPrefix = '';
|
$relPrefix = '';
|
||||||
} else {
|
} else {
|
||||||
$parts = array_filter(explode('/', $folder), fn($p) => $p !== '');
|
$parts = array_filter(explode('/', $folder), fn($p) => $p !== '');
|
||||||
foreach ($parts as $seg) {
|
foreach ($parts as $seg) {
|
||||||
if (!self::isSafeSegment($seg)) {
|
if (!self::isSafeSegment($seg)) {
|
||||||
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
|
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$guess = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
|
$guess = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
|
||||||
$dir = self::safeReal($base, $guess);
|
$dir = self::safeReal($base, $guess);
|
||||||
if ($dir === null || !is_dir($dir)) {
|
if ($dir === null || !is_dir($dir)) {
|
||||||
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
|
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
|
||||||
}
|
}
|
||||||
$relPrefix = implode('/', $parts);
|
$relPrefix = implode('/', $parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
$IGNORE = ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db'];
|
$IGNORE = ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db'];
|
||||||
$SKIP = ['trash', 'profile_pics'];
|
$SKIP = ['trash', 'profile_pics'];
|
||||||
|
|
||||||
$entries = @scandir($dir);
|
$entries = @scandir($dir);
|
||||||
if ($entries === false) {
|
if ($entries === false) {
|
||||||
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
|
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
|
||||||
}
|
}
|
||||||
|
|
||||||
$folderCount = 0;
|
$folderCount = 0;
|
||||||
$fileCount = 0;
|
$fileCount = 0;
|
||||||
$totalBytes = 0;
|
$totalBytes = 0;
|
||||||
|
|
||||||
$MAX_SCAN = 4000;
|
// NEW: stats for created / modified
|
||||||
$scanned = 0;
|
$earliestUploaded = null; // min mtime
|
||||||
|
$latestMtime = null; // max mtime
|
||||||
foreach ($entries as $name) {
|
|
||||||
if (++$scanned > $MAX_SCAN) {
|
$MAX_SCAN = 4000;
|
||||||
break;
|
$scanned = 0;
|
||||||
}
|
|
||||||
|
foreach ($entries as $name) {
|
||||||
if ($name === '.' || $name === '..') continue;
|
if (++$scanned > $MAX_SCAN) {
|
||||||
if ($name[0] === '.') continue;
|
break;
|
||||||
if (in_array($name, $IGNORE, true)) continue;
|
}
|
||||||
if (in_array(strtolower($name), $SKIP, true)) continue;
|
|
||||||
if (!self::isSafeSegment($name)) continue;
|
if ($name === '.' || $name === '..') continue;
|
||||||
|
if ($name[0] === '.') continue;
|
||||||
$abs = $dir . DIRECTORY_SEPARATOR . $name;
|
if (in_array($name, $IGNORE, true)) continue;
|
||||||
|
if (in_array(strtolower($name), $SKIP, true)) continue;
|
||||||
if (@is_dir($abs)) {
|
if (!self::isSafeSegment($name)) continue;
|
||||||
if (@is_link($abs)) {
|
|
||||||
$safe = self::safeReal($base, $abs);
|
$abs = $dir . DIRECTORY_SEPARATOR . $name;
|
||||||
if ($safe === null || !is_dir($safe)) {
|
|
||||||
continue;
|
if (@is_dir($abs)) {
|
||||||
}
|
if (@is_link($abs)) {
|
||||||
}
|
$safe = self::safeReal($base, $abs);
|
||||||
|
if ($safe === null || !is_dir($safe)) {
|
||||||
$childRel = ($relPrefix === '' ? $name : $relPrefix . '/' . $name);
|
continue;
|
||||||
if (
|
}
|
||||||
ACL::isAdmin($perms)
|
}
|
||||||
|| ACL::canRead($user, $perms, $childRel)
|
|
||||||
|| ACL::canReadOwn($user, $perms, $childRel)
|
$childRel = ($relPrefix === '' ? $name : $relPrefix . '/' . $name);
|
||||||
) {
|
if (
|
||||||
$folderCount++;
|
ACL::isAdmin($perms)
|
||||||
}
|
|| ACL::canRead($user, $perms, $childRel)
|
||||||
} elseif (@is_file($abs)) {
|
|| ACL::canReadOwn($user, $perms, $childRel)
|
||||||
// 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.
|
$folderCount++;
|
||||||
if (!$hasFullRead) {
|
}
|
||||||
continue;
|
} 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.
|
||||||
$fileCount++;
|
if (!$hasFullRead) {
|
||||||
$sz = @filesize($abs);
|
continue;
|
||||||
if (is_int($sz) && $sz > 0) {
|
}
|
||||||
$totalBytes += $sz;
|
|
||||||
}
|
$fileCount++;
|
||||||
}
|
$sz = @filesize($abs);
|
||||||
}
|
if (is_int($sz) && $sz > 0) {
|
||||||
|
$totalBytes += $sz;
|
||||||
return [
|
}
|
||||||
'folders' => $folderCount,
|
|
||||||
'files' => $fileCount,
|
// NEW: track earliest / latest mtime from visible files
|
||||||
'bytes' => $totalBytes,
|
$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) */
|
/* Helpers (private) */
|
||||||
private static function isSafeSegment(string $name): bool
|
private static function isSafeSegment(string $name): bool
|
||||||
@@ -483,6 +508,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.
|
* Deletes a folder if it is empty and removes its corresponding metadata.
|
||||||
* Also removes ownership mappings for this folder and all its descendants.
|
* Also removes ownership mappings for this folder and all its descendants.
|
||||||
|
|||||||
Reference in New Issue
Block a user