Compare commits

...

11 Commits

24 changed files with 3914 additions and 768 deletions

View File

@@ -1,5 +1,87 @@
# Changelog
## Changes 11/30/2025 (v2.2.4)
release(v2.2.4): fix(admin): ONLYOFFICE JWT save crash and respect replace/locked flags
- Prevented a JS crash when the ONLYOFFICE JWT field isnt present by always initializing payload.onlyoffice before touching jwtSecret.
- Tightened ONLYOFFICE JWT handling so the secret is only sent when config isnt locked by PHP and the admin explicitly chooses Replace (or is setting it for the first time), instead of always pushing whatever is in the field.
---
## 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 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

@@ -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.
- 🧩 **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.
- 📊 **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.
- 🔑 **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)

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

1684
public/js/adminStorage.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -179,9 +179,22 @@ export function buildFileTableRow(file, folderPath) {
const safeUploader = escapeHTML(file.uploader || "Unknown");
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 = "";
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>`;
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(file.name)) {
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)) {
previewIcon = `<i class="material-icons">audiotrack</i>`;
}
previewButton = `<button
type="button"
class="btn btn-sm btn-info preview-btn"
data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
data-preview-name="${safeFileName}"
title="${t('preview')}">
${previewIcon}
</button>`;
previewButton = `
<button
type="button"
class="btn btn-sm btn-info preview-btn"
data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
data-preview-name="${safeFileName}"
title="${t('preview')}">
${previewIcon}
</button>`;
}
return `
@@ -242,13 +257,13 @@ export function buildFileTableRow(file, folderPath) {
<i class="material-icons">drive_file_rename_outline</i>
</button>
<!-- share -->
<button
type="button"
class="btn btn-secondary btn-sm share-btn ms-1"
data-file="${safeFileName}"
title="${t('share')}">
<i class="material-icons">share</i>
</button>
<button
type="button"
class="btn btn-secondary btn-sm share-btn ms-1"
data-file="${safeFileName}"
title="${t('share')}">
<i class="material-icons">share</i>
</button>
</div>
</td>
</tr>

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

@@ -295,6 +295,27 @@ try {
// Global flag for advanced search mode.
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)
=========================================================== */
@@ -428,19 +449,19 @@ function attachStripIconAsync(hostEl, fullPath, size = 28) {
// make sure this brand-new SVG is sized correctly
try { syncFolderIconSizeToRowHeight(); } catch {}
const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(fullPath)}&t=${Date.now()}`;
_fetchJSONWithTimeout(url, 2500)
.then(({ folders = 0, files = 0 }) => {
if ((folders + files) > 0 && iconSpan.dataset.kind !== 'paper') {
// 2) swap to "paper" icon
iconSpan.dataset.kind = 'paper';
iconSpan.innerHTML = folderSVG('paper');
fetchFolderStats(fullPath)
.then(stats => {
if (!stats) return;
const folders = Number.isFinite(stats.folders) ? stats.folders : 0;
const files = Number.isFinite(stats.files) ? stats.files : 0;
// re-apply sizing to this new SVG too
try { syncFolderIconSizeToRowHeight(); } catch {}
}
})
.catch(() => { /* ignore */ });
if ((folders + files) > 0 && iconSpan.dataset.kind !== 'paper') {
iconSpan.dataset.kind = 'paper';
iconSpan.innerHTML = folderSVG('paper');
try { syncFolderIconSizeToRowHeight(); } catch {}
}
})
.catch(() => {});
}
/* -----------------------------
@@ -1156,6 +1177,19 @@ function injectInlineFolderRows(fileListContent, folder, pageSubfolders) {
);
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
tbody.querySelectorAll("tr.folder-row").forEach(tr => tr.remove());
@@ -1356,19 +1390,32 @@ if (iconSpan) {
iconSpan.style.marginTop = "0px"; // small down nudge
}
// ----- FOLDER STATS + OWNER + CAPS (keep your existing code below here) -----
const sizeCellIndex = (sizeIdx >= 0 && sizeIdx < tr.cells.length) ? sizeIdx : -1;
const nameCellIndex = (nameIdx >= 0 && nameIdx < tr.cells.length) ? nameIdx : -1;
// ----- FOLDER STATS + OWNER + CAPS -----
const sizeCellIndex = (sizeIdx >= 0 && sizeIdx < tr.cells.length) ? sizeIdx : -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()}`;
_fetchJSONWithTimeout(url, 2500).then(stats => {
fetchFolderStats(sf.full).then(stats => {
if (!stats) return;
const foldersCount = Number.isFinite(stats.folders) ? stats.folders : 0;
const filesCount = Number.isFinite(stats.files) ? stats.files : 0;
const bytes = Number.isFinite(stats.bytes)
? stats.bytes
: (Number.isFinite(stats.sizeBytes) ? stats.sizeBytes : null);
// Try multiple possible size keys so backend + JS can drift a bit
let bytes = 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 = [];
if (foldersCount) pieces.push(`${foldersCount} folder${foldersCount === 1 ? "" : "s"}`);
@@ -1395,6 +1442,26 @@ if (iconSpan) {
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(() => {
if (sizeCellIndex >= 0) {
const sizeCell = tr.cells[sizeCellIndex];
@@ -1887,7 +1954,7 @@ export function renderGalleryView(folder, container) {
// 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
if (window.imageCache && window.imageCache[cacheKey]) {
thumbnail = `<img
@@ -1931,7 +1998,7 @@ export function renderGalleryView(folder, container) {
galleryHTML += `
<div class="gallery-card"
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"
class="file-checkbox"
id="cb-${idSafe}"

View File

@@ -120,7 +120,12 @@ export function openShareModal(file, folder) {
}
/* -------------------------------- 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 *dont* inline it
const SVG_RE = /\.svg$/i;
const VID_RE = /\.(mp4|mkv|webm|mov|ogv)$/i;
const AUD_RE = /\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/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 name = fileName;
const lower = (name || '').toLowerCase();
const isSvg = SVG_RE.test(lower);
const isImage = IMG_RE.test(lower);
const isVideo = VID_RE.test(lower);
const isAudio = AUD_RE.test(lower);
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 -------------------- */
if (isImage) {

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,10 @@ 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.",
"svg_preview_disabled": "SVG preview is disabled for now for security reasons."
},
es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",

View File

@@ -1,2 +1,2 @@
// generated by CI
window.APP_VERSION = 'v2.1.0';
window.APP_VERSION = 'v2.2.4';

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

@@ -503,13 +503,13 @@ class FileModel {
if (!preg_match(REGEX_FILE_NAME, $file)) {
return ["error" => "Invalid file name."];
}
// Determine the real upload directory.
$uploadDirReal = realpath(UPLOAD_DIR);
if ($uploadDirReal === false) {
return ["error" => "Server misconfiguration."];
}
// Determine directory based on folder.
if (strtolower($folder) === 'root' || trim($folder) === '') {
$directory = $uploadDirReal;
@@ -524,11 +524,11 @@ class FileModel {
return ["error" => "Invalid folder path."];
}
}
// Build the file path.
$filePath = $directory . DIRECTORY_SEPARATOR . $file;
$filePath = $directory . DIRECTORY_SEPARATOR . $file;
$realFilePath = realpath($filePath);
// Ensure the file exists and is within the allowed directory.
if ($realFilePath === false || strpos($realFilePath, $uploadDirReal) !== 0) {
return ["error" => "Access forbidden."];
@@ -536,19 +536,86 @@ class FileModel {
if (!file_exists($realFilePath)) {
return ["error" => "File not found."];
}
// Get the MIME type with safe fallback.
$mimeType = function_exists('mime_content_type') ? mime_content_type($realFilePath) : null;
if (!$mimeType) {
$mimeType = 'application/octet-stream';
}
// OPTIONAL: normalize SVG MIME
$ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION));
if ($ext === 'svg') {
$mimeType = 'image/svg+xml';
}
return [
"filePath" => $realFilePath,
"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.
*

View File

@@ -12,110 +12,135 @@ class FolderModel
* ============================================================ */
public static function countVisible(string $folder, string $user, array $perms): array
{
$folder = ACL::normalizeFolder($folder);
// If the user can't view this folder at all, short-circuit (admin/read/read_own)
$canViewFolder = ACL::isAdmin($perms)
|| ACL::canRead($user, $perms, $folder)
|| ACL::canReadOwn($user, $perms, $folder);
if (!$canViewFolder) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
}
// NEW: distinguish full read vs own-only for this folder
$hasFullRead = ACL::isAdmin($perms) || ACL::canRead($user, $perms, $folder);
// if !$hasFullRead but $canViewFolder is true, theyre effectively "view own" only
$base = realpath((string)UPLOAD_DIR);
if ($base === false) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
}
// Resolve target dir + ACL-relative prefix
if ($folder === 'root') {
$dir = $base;
$relPrefix = '';
} else {
$parts = array_filter(explode('/', $folder), fn($p) => $p !== '');
foreach ($parts as $seg) {
if (!self::isSafeSegment($seg)) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
}
}
$guess = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
$dir = self::safeReal($base, $guess);
if ($dir === null || !is_dir($dir)) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
}
$relPrefix = implode('/', $parts);
}
$IGNORE = ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db'];
$SKIP = ['trash', 'profile_pics'];
$entries = @scandir($dir);
if ($entries === false) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
}
$folderCount = 0;
$fileCount = 0;
$totalBytes = 0;
$MAX_SCAN = 4000;
$scanned = 0;
foreach ($entries as $name) {
if (++$scanned > $MAX_SCAN) {
break;
}
if ($name === '.' || $name === '..') continue;
if ($name[0] === '.') continue;
if (in_array($name, $IGNORE, true)) continue;
if (in_array(strtolower($name), $SKIP, true)) continue;
if (!self::isSafeSegment($name)) continue;
$abs = $dir . DIRECTORY_SEPARATOR . $name;
if (@is_dir($abs)) {
if (@is_link($abs)) {
$safe = self::safeReal($base, $abs);
if ($safe === null || !is_dir($safe)) {
continue;
}
}
$childRel = ($relPrefix === '' ? $name : $relPrefix . '/' . $name);
if (
ACL::isAdmin($perms)
|| ACL::canRead($user, $perms, $childRel)
|| ACL::canReadOwn($user, $perms, $childRel)
) {
$folderCount++;
}
} elseif (@is_file($abs)) {
// Only count files if the user has full read on *this* folder.
// If theyre view_own-only here, dont leak or mis-report counts.
if (!$hasFullRead) {
continue;
}
$fileCount++;
$sz = @filesize($abs);
if (is_int($sz) && $sz > 0) {
$totalBytes += $sz;
}
}
}
return [
'folders' => $folderCount,
'files' => $fileCount,
'bytes' => $totalBytes,
];
}
{
$folder = ACL::normalizeFolder($folder);
// If the user can't view this folder at all, short-circuit (admin/read/read_own)
$canViewFolder = ACL::isAdmin($perms)
|| ACL::canRead($user, $perms, $folder)
|| ACL::canReadOwn($user, $perms, $folder);
if (!$canViewFolder) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
}
// NEW: distinguish full read vs own-only for this folder
$hasFullRead = ACL::isAdmin($perms) || ACL::canRead($user, $perms, $folder);
// if !$hasFullRead but $canViewFolder is true, theyre effectively "view own" only
$base = realpath((string)UPLOAD_DIR);
if ($base === false) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
}
// Resolve target dir + ACL-relative prefix
if ($folder === 'root') {
$dir = $base;
$relPrefix = '';
} else {
$parts = array_filter(explode('/', $folder), fn($p) => $p !== '');
foreach ($parts as $seg) {
if (!self::isSafeSegment($seg)) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
}
}
$guess = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
$dir = self::safeReal($base, $guess);
if ($dir === null || !is_dir($dir)) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
}
$relPrefix = implode('/', $parts);
}
$IGNORE = ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db'];
$SKIP = ['trash', 'profile_pics'];
$entries = @scandir($dir);
if ($entries === false) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
}
$folderCount = 0;
$fileCount = 0;
$totalBytes = 0;
// NEW: stats for created / modified
$earliestUploaded = null; // min mtime
$latestMtime = null; // max mtime
$MAX_SCAN = 4000;
$scanned = 0;
foreach ($entries as $name) {
if (++$scanned > $MAX_SCAN) {
break;
}
if ($name === '.' || $name === '..') continue;
if ($name[0] === '.') continue;
if (in_array($name, $IGNORE, true)) continue;
if (in_array(strtolower($name), $SKIP, true)) continue;
if (!self::isSafeSegment($name)) continue;
$abs = $dir . DIRECTORY_SEPARATOR . $name;
if (@is_dir($abs)) {
if (@is_link($abs)) {
$safe = self::safeReal($base, $abs);
if ($safe === null || !is_dir($safe)) {
continue;
}
}
$childRel = ($relPrefix === '' ? $name : $relPrefix . '/' . $name);
if (
ACL::isAdmin($perms)
|| ACL::canRead($user, $perms, $childRel)
|| ACL::canReadOwn($user, $perms, $childRel)
) {
$folderCount++;
}
} elseif (@is_file($abs)) {
// Only count files if the user has full read on *this* folder.
// If theyre view_own-only here, dont leak or mis-report counts.
if (!$hasFullRead) {
continue;
}
$fileCount++;
$sz = @filesize($abs);
if (is_int($sz) && $sz > 0) {
$totalBytes += $sz;
}
// NEW: track earliest / latest mtime from visible files
$mt = @filemtime($abs);
if (is_int($mt) && $mt > 0) {
if ($earliestUploaded === null || $mt < $earliestUploaded) {
$earliestUploaded = $mt;
}
if ($latestMtime === null || $mt > $latestMtime) {
$latestMtime = $mt;
}
}
}
}
$result = [
'folders' => $folderCount,
'files' => $fileCount,
'bytes' => $totalBytes,
];
// Only include when we actually saw at least one readable file
if ($earliestUploaded !== null) {
$result['earliest_uploaded'] = date(DATE_TIME_FORMAT, $earliestUploaded);
}
if ($latestMtime !== null) {
$result['latest_mtime'] = date(DATE_TIME_FORMAT, $latestMtime);
}
return $result;
}
/* Helpers (private) */
private static function isSafeSegment(string $name): bool
@@ -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.
* Also removes ownership mappings for this folder and all its descendants.