mirror of
https://github.com/error311/FileRise.git
synced 2025-12-21 10:59:38 -06:00
release(v2.8.0): OIDC public clients + Storage scan log/snapshot controls + sidebar zone order
This commit is contained in:
32
CHANGELOG.md
32
CHANGELOG.md
@@ -1,5 +1,37 @@
|
||||
# Changelog
|
||||
|
||||
## Changes 12/14/2025 (v2.8.0)
|
||||
|
||||
release(v2.8.0): OIDC public clients + Storage scan log/snapshot controls + sidebar zone order
|
||||
|
||||
**Added**
|
||||
|
||||
- **OIDC “Public Client” mode** (no client secret) via new `oidc.publicClient` flag, with automatic secret clearing when enabled.
|
||||
- Admin UI toggle + guidance for Public Clients (PKCE S256 / token endpoint auth method “none”).
|
||||
- **Storage / Disk Usage**
|
||||
- Centralized scan log path + “tail” reader and snapshot delete helper in `DiskUsageModel`.
|
||||
- New admin endpoint: `POST /api/admin/diskUsageDeleteSnapshot.php` (admin-only, best-effort CSRF).
|
||||
- Admin Storage UI button **“Delete snapshot”** wired into the panel.
|
||||
|
||||
**Improved**
|
||||
|
||||
- OIDC client creation:
|
||||
- Trims secrets, sets `clientSecret = null` for public clients.
|
||||
- Defaults token endpoint auth to **`none`** for public clients vs **`client_secret_basic`** for confidential clients (unless explicitly overridden).
|
||||
- Storage scan UX:
|
||||
- API includes scan log tail metadata in disk usage summary responses and avoids noisy 404s when snapshot is missing.
|
||||
- Trigger scan uses the shared log path, returns `logMtime`, and falls back to a foreground run if background exec fails.
|
||||
- Polling detects stalls/timeouts and surfaces log tail/path in the UI.
|
||||
- Drag/drop zones: persist **sidebar card order** (not just zone placement) + minor animation tuning.
|
||||
- `FR_OIDC_DEBUG` can now be enabled via env var parsing (`1/true/yes/on`).
|
||||
- Reduced console noise: `diskUsageChildren` returns HTTP 200 (`ok=false`) for `no_snapshot` instead of 404.
|
||||
|
||||
**UI / CSS**
|
||||
|
||||
- `styles.css` cleanup with a table of contents + section headers/comments for easier navigation.
|
||||
|
||||
---
|
||||
|
||||
## Changes 12/13/2025 (v2.7.1)
|
||||
|
||||
release(v2.7.1): harden share endpoint headers + suppress deprecated output
|
||||
|
||||
@@ -75,7 +75,13 @@ if (!defined('FR_OIDC_ALLOW_DEMOTE')) {
|
||||
// so AuthModel::isOidcDemoteAllowed() will fall back to AdminModel::getConfig().
|
||||
}
|
||||
if (!defined('FR_OIDC_DEBUG')) {
|
||||
define('FR_OIDC_DEBUG', false);
|
||||
$envVal = getenv('FR_OIDC_DEBUG');
|
||||
if ($envVal !== false && $envVal !== '') {
|
||||
$val = strtolower(trim((string)$envVal));
|
||||
define('FR_OIDC_DEBUG', in_array($val, ['1', 'true', 'yes', 'on'], true));
|
||||
} else {
|
||||
define('FR_OIDC_DEBUG', false);
|
||||
}
|
||||
}
|
||||
// Antivirus / ClamAV (optional)
|
||||
// If VIRUS_SCAN_ENABLED is set in the environment, it overrides the admin setting.
|
||||
@@ -363,4 +369,4 @@ if (!defined('FR_PRO_INFO')) {
|
||||
}
|
||||
if (!defined('FR_PRO_BUNDLE_VERSION')) {
|
||||
define('FR_PRO_BUNDLE_VERSION', null);
|
||||
}
|
||||
}
|
||||
|
||||
53
public/api/admin/diskUsageDeleteSnapshot.php
Normal file
53
public/api/admin/diskUsageDeleteSnapshot.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
// public/api/admin/diskUsageDeleteSnapshot.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';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Optional CSRF guard (best-effort; mirrors other admin endpoints)
|
||||
$csrf = (string)($_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
|
||||
$meta = (string)($_SESSION['csrf_token'] ?? '');
|
||||
if ($meta !== '' && $csrf !== '' && !hash_equals($meta, $csrf)) {
|
||||
http_response_code(400);
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'error' => 'csrf_mismatch',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$deleted = DiskUsageModel::deleteSnapshot();
|
||||
http_response_code(200);
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'deleted' => $deleted,
|
||||
'snapshot' => DiskUsageModel::snapshotPath(),
|
||||
], JSON_UNESCAPED_SLASHES);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'error' => 'internal_error',
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
@@ -25,11 +25,20 @@ if (!$authenticated || !$isAdmin) {
|
||||
|
||||
// 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;
|
||||
$topFiles = isset($_GET['topFiles']) ? max(0, (int)$_GET['topFiles']) : 0;
|
||||
|
||||
try {
|
||||
$summary = DiskUsageModel::getSummary($topFolders, $topFiles);
|
||||
http_response_code($summary['ok'] ? 200 : 404);
|
||||
$logInfo = DiskUsageModel::readScanLogTail();
|
||||
if ($logInfo !== null) {
|
||||
$summary['scanLog'] = $logInfo;
|
||||
}
|
||||
// Avoid noisy 404s in console when snapshot doesn't exist yet; still include ok=false
|
||||
if (!$summary['ok'] && ($summary['error'] ?? '') === 'no_snapshot') {
|
||||
http_response_code(200);
|
||||
} else {
|
||||
http_response_code($summary['ok'] ? 200 : 404);
|
||||
}
|
||||
echo json_encode($summary, JSON_UNESCAPED_SLASHES);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
@@ -38,4 +47,4 @@ try {
|
||||
'error' => 'internal_error',
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,10 +72,7 @@ try {
|
||||
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';
|
||||
$logFile = DiskUsageModel::scanLogPath();
|
||||
|
||||
// nohup php disk_usage_scan.php >> log 2>&1 & echo $!
|
||||
$cmdStr =
|
||||
@@ -85,12 +82,31 @@ try {
|
||||
$pid = @shell_exec('/bin/sh -c ' . escapeshellarg($cmdStr));
|
||||
$pid = is_string($pid) ? (int)trim($pid) : 0;
|
||||
|
||||
// If background launch failed (pid 0), fall back to a foreground run so the snapshot
|
||||
// still completes and the UI doesn't spin forever on hosts that block background exec.
|
||||
if ($pid <= 0) {
|
||||
$rc = 1;
|
||||
@exec(
|
||||
escapeshellcmd($php) . ' ' . escapeshellarg($worker) .
|
||||
' >> ' . escapeshellarg($logFile) . ' 2>&1',
|
||||
$out,
|
||||
$rc
|
||||
);
|
||||
|
||||
if ($rc !== 0) {
|
||||
throw new RuntimeException('Failed to launch disk usage scan (exec/whitelist issue?). See log: ' . $logFile);
|
||||
}
|
||||
// Foreground run finished; no pid to return.
|
||||
$pid = null;
|
||||
}
|
||||
|
||||
http_response_code(200);
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'pid' => $pid > 0 ? $pid : null,
|
||||
'message' => 'Disk usage scan started in the background.',
|
||||
'logFile' => $logFile,
|
||||
'logMtime'=> is_file($logFile) ? (int)@filemtime($logFile) : null,
|
||||
], JSON_UNESCAPED_SLASHES);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
@@ -99,4 +115,4 @@ try {
|
||||
'error' => 'internal_error',
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,14 +31,15 @@ try {
|
||||
// Client ID / secret presence flags (never leak actual values)
|
||||
$clientId = $oidcCfg['clientId'] ?? ($cfg['oidc_client_id'] ?? null);
|
||||
$clientSecret = $oidcCfg['clientSecret'] ?? ($cfg['oidc_client_secret'] ?? null);
|
||||
$publicClient = !empty($oidcCfg['publicClient']);
|
||||
|
||||
$clientIdMode = 'unset';
|
||||
if ($clientId !== null && $clientId !== '') {
|
||||
$clientIdMode = 'present';
|
||||
}
|
||||
|
||||
$clientSecretMode = 'none';
|
||||
if ($clientSecret !== null && $clientSecret !== '') {
|
||||
$clientSecretMode = $publicClient ? 'public_client' : 'none';
|
||||
if (!$publicClient && $clientSecret !== null && $clientSecret !== '') {
|
||||
$clientSecretMode = 'present';
|
||||
}
|
||||
|
||||
@@ -47,6 +48,9 @@ try {
|
||||
if (defined('OIDC_TOKEN_ENDPOINT_AUTH_METHOD') && OIDC_TOKEN_ENDPOINT_AUTH_METHOD) {
|
||||
$tokenAuthMethod = OIDC_TOKEN_ENDPOINT_AUTH_METHOD;
|
||||
}
|
||||
if (!$tokenAuthMethod) {
|
||||
$tokenAuthMethod = $publicClient ? 'none' : 'client_secret_basic';
|
||||
}
|
||||
|
||||
$loginOptions = is_array($cfg['loginOptions'] ?? null) ? $cfg['loginOptions'] : [];
|
||||
|
||||
@@ -56,6 +60,7 @@ try {
|
||||
|
||||
'clientIdMode' => $clientIdMode,
|
||||
'clientSecretMode' => $clientSecretMode,
|
||||
'publicClient' => $publicClient,
|
||||
|
||||
'debugFlag' => [
|
||||
'FR_OIDC_DEBUG' => defined('FR_OIDC_DEBUG') ? (bool)FR_OIDC_DEBUG : false,
|
||||
@@ -98,4 +103,4 @@ try {
|
||||
'error' => 'Internal error: ' . $e->getMessage(),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,12 @@ $folderKey = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
|
||||
try {
|
||||
/** @var array $result */
|
||||
$result = ProDiskUsage::getChildren($folderKey);
|
||||
http_response_code(!empty($result['ok']) ? 200 : 404);
|
||||
// Avoid noisy 404s in console when snapshot is missing; still return ok=false
|
||||
if (empty($result['ok']) && ($result['error'] ?? '') === 'no_snapshot') {
|
||||
http_response_code(200);
|
||||
} else {
|
||||
http_response_code(!empty($result['ok']) ? 200 : 404);
|
||||
}
|
||||
echo json_encode($result, JSON_UNESCAPED_SLASHES);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
@@ -50,4 +55,4 @@ try {
|
||||
'error' => 'internal_error',
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
/* === FileRise styles.css TOC ===
|
||||
1) Variables
|
||||
2) Base
|
||||
3) Layout
|
||||
4) Components
|
||||
5) Modals
|
||||
6) Utilities
|
||||
7) Dark Mode
|
||||
8) Overrides / Compact / Legacy
|
||||
*/
|
||||
/* === Variables === */
|
||||
:root{--header-h: 55px;}
|
||||
.header-container{min-height: var(--header-h);}
|
||||
img.logo{width:50px; height:50px; display:block;}
|
||||
.is-visually-hidden{visibility: hidden;
|
||||
pointer-events: none;}
|
||||
/* === Modals === */
|
||||
#userPanelModal .modal-content,
|
||||
#adminPanelModal .modal-content,
|
||||
#userPermissionsModal .modal-content,
|
||||
@@ -18,9 +30,11 @@ img.logo{width:50px; height:50px; display:block;}
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
margin-bottom: 10px;}
|
||||
/* === Layout === */
|
||||
.main-wrapper{display:flex;
|
||||
gap:5px;
|
||||
align-items:flex-start;}
|
||||
/* === Base === */
|
||||
body{font-family: 'Roboto', sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
margin: 0;
|
||||
@@ -42,6 +56,7 @@ body{letter-spacing: 0.2px;
|
||||
@media (max-width: 600px) {
|
||||
.zones-toggle{left: 85px !important;}
|
||||
}
|
||||
/* === Components === */
|
||||
:root{--filr-accent-500:#008CB4;
|
||||
--filr-accent-600:#00789A;
|
||||
--filr-accent-700:#006882;
|
||||
@@ -126,19 +141,11 @@ body{letter-spacing: 0.2px;
|
||||
color: #c8e6c9;
|
||||
}
|
||||
/* FileRise Pro button styling (admin) */
|
||||
.btn-pro-admin {
|
||||
background: linear-gradient(135deg, #ff9800, #ff5722);
|
||||
border-color: #ff9800;
|
||||
color: #1b0f00 !important;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 0 10px rgba(255, 152, 0, 0.4);
|
||||
}
|
||||
|
||||
.btn-pro-admin:hover {
|
||||
.btn-pro-admin:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
/* User management action bar */
|
||||
/* TODO: admin-user-actions also redefined below with different margins; keep both until spacing contexts are confirmed. */
|
||||
.admin-user-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1566,6 +1573,7 @@ label{font-size: 0.9rem;}
|
||||
#restoreFilesList li input[type="checkbox"]{margin: 0 !important;
|
||||
transform: translateY(-3px) !important;}
|
||||
#restoreFilesList li label{margin-left: 8px !important;}
|
||||
/* === Context Menus === */
|
||||
.filr-menu{position: fixed;
|
||||
z-index: 9999;
|
||||
min-width: 220px;
|
||||
@@ -1616,6 +1624,7 @@ label{font-size: 0.9rem;}
|
||||
#folderContextMenu.filr-menu{max-height: min(calc(100vh - 16px), 420px);
|
||||
overflow-y: auto;}
|
||||
#folderContextMenu .material-icons{color: currentColor; opacity: .9;}
|
||||
/* === Sidebar / Drag & Drop Zones === */
|
||||
.drop-target-sidebar{display: none;
|
||||
background-color: #f8f9fa;
|
||||
border-right: 2px dashed #1565C0;
|
||||
@@ -1715,6 +1724,7 @@ body.dark-mode #folderManagementCard{border-color: var(--card-border-dark, #3a3a
|
||||
.custom-folder-card-body{padding-top: 5px !important;
|
||||
padding-right: 0 !important;
|
||||
padding-left: 0 !important;}
|
||||
/* === Modals (admin/user panels) === */
|
||||
#addUserModal,
|
||||
#removeUserModal{z-index: 5000 !important;}
|
||||
#customConfirmModal{z-index: 12000 !important;}
|
||||
@@ -1733,6 +1743,7 @@ body.dark-mode #folderManagementCard{border-color: var(--card-border-dark, #3a3a
|
||||
padding: 6px 12px;
|
||||
font-size: 14px;}
|
||||
#changePasswordModal{z-index: 9999;}
|
||||
/* === Utilities === */
|
||||
@keyframes spin {
|
||||
0%{transform: rotate(0deg);}
|
||||
100%{transform: rotate(360deg);}
|
||||
@@ -1803,6 +1814,7 @@ body:not(.dark-mode){--download-spinner-color: #000;}
|
||||
margin: 0;
|
||||
padding: 0;}
|
||||
.dark-mode #fileSummary{color: white;}
|
||||
/* TODO: searchIcon also redefined later with alternate sizing/pill treatment; keep both until usages converge. */
|
||||
#searchIcon{border-radius: 4px;
|
||||
padding: 4px 8px;}
|
||||
.dark-mode #searchIcon{background-color: #444;
|
||||
@@ -1834,7 +1846,7 @@ body:not(.dark-mode){--download-spinner-color: #000;}
|
||||
inset 0 -1px 0 var(--filr-row-outline-hover);}
|
||||
.dark-mode .btn-icon .material-icons,
|
||||
.dark-mode #searchIcon .material-icons{color: #fff;}
|
||||
.dark-mode .btn-icon:hover,
|
||||
.dark-mode .btn-icon:hover,
|
||||
.dark-mode .btn-icon:focus{background: rgba(255, 255, 255, 0.1);}
|
||||
.user-dropdown{position: relative;
|
||||
display: inline-block;}
|
||||
@@ -1849,23 +1861,23 @@ body:not(.dark-mode){--download-spinner-color: #000;}
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
||||
z-index: 1000;}
|
||||
.user-dropdown .user-menu.show{display: block;}
|
||||
.user-dropdown .user-menu .item{padding: 0.5rem 0.75rem;
|
||||
.user-dropdown .user-menu .item{padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;}
|
||||
.user-dropdown .dropdown-caret{border-top: 5px solid currentColor;
|
||||
.user-dropdown .dropdown-caret{border-top: 5px solid currentColor;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-left: 0.25rem;}
|
||||
.dark-mode .user-dropdown .user-menu{background: #2c2c2c;
|
||||
.dark-mode .user-dropdown .user-menu{background: #2c2c2c;
|
||||
border-color: #444;}
|
||||
.dark-mode .user-dropdown .user-menu .item{color: #e0e0e0;}
|
||||
.user-dropdown .dropdown-username{margin: 0 8px;
|
||||
.dark-mode .user-dropdown .user-menu .item{color: #e0e0e0;}
|
||||
.user-dropdown .dropdown-username{margin: 0 8px;
|
||||
font-weight: 500;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;}
|
||||
.user-dropdown .user-menu{border-radius: var(--menu-radius);
|
||||
.user-dropdown .user-menu{border-radius: var(--menu-radius);
|
||||
overflow: hidden;
|
||||
backdrop-filter: saturate(140%) blur(2px);}
|
||||
.user-dropdown .user-menu .item{padding: 0.5rem 0.75rem;
|
||||
@@ -2051,6 +2063,7 @@ body.dark-mode .folder-strip-container .folder-item:hover{background-color: rgba
|
||||
line-height: 1.2;
|
||||
margin-top: 2px;
|
||||
}
|
||||
/* === Folder Tree (detailed icons + rows) === */
|
||||
#folderTreeContainer .folder-icon{flex: 0 0 var(--icon-size);
|
||||
width: var(--icon-size); height: var(--icon-size);
|
||||
display: inline-flex; align-items: center; justify-content: center;}
|
||||
@@ -2179,6 +2192,7 @@ body.dark-mode #folderTreeContainer .folder-icon .lock-keyhole{fill: rgba(255,25
|
||||
@keyframes filr-spin {
|
||||
to{transform: rotate(360deg);}
|
||||
}
|
||||
/* === Upload resume banner === */
|
||||
#resumableDraftBanner.upload-resume-banner{margin: 8px 12px 12px;}
|
||||
.upload-resume-banner-inner{display: flex;
|
||||
align-items: center;
|
||||
@@ -2188,19 +2202,21 @@ body.dark-mode #folderTreeContainer .folder-icon .lock-keyhole{fill: rgba(255,25
|
||||
background: rgba(255, 152, 0, 0.06);
|
||||
border: 1px solid rgba(255, 152, 0, 0.55);
|
||||
font-size: 0.9rem;}
|
||||
.upload-resume-banner-inner .material-icons,
|
||||
.folder-badge .material-icons{font-size: 20px;
|
||||
.upload-resume-banner-inner .material-icons,
|
||||
.folder-badge .material-icons{font-size: 20px;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
background-color: transparent;
|
||||
vertical-align: middle;
|
||||
background-color: transparent;
|
||||
color: #111;}
|
||||
.dark-mode .upload-resume-banner-inner .material-icons,
|
||||
.dark-mode .folder-badge .material-icons{background-color: transparent;
|
||||
color: #f5f5f5;}
|
||||
.dark-mode .upload-resume-banner-inner .material-icons,
|
||||
.dark-mode .folder-badge .material-icons{background-color: transparent;
|
||||
color: #f5f5f5;}
|
||||
/* === Folder Strip (header chips/list) === */
|
||||
/* TODO: folder-strip-container is also defined earlier with fuller styling; keeping both until combined layout states are clarified. */
|
||||
/* Base strip container */
|
||||
.folder-strip-container {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.folder-strip-container {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* Base item layout */
|
||||
.folder-strip-container .folder-item {
|
||||
@@ -2287,6 +2303,7 @@ body.dark-mode #folderTreeContainer .folder-icon .lock-keyhole{fill: rgba(255,25
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* === Overrides / Compact / Legacy === */
|
||||
/* ============================================
|
||||
FileRise polish – compact theme layer
|
||||
============================================ */
|
||||
@@ -2295,14 +2312,16 @@ body.dark-mode #folderTreeContainer .folder-icon .lock-keyhole{fill: rgba(255,25
|
||||
#customToast{border-radius:999px}
|
||||
#folderTreeContainer .folder-row{border-radius:8px}
|
||||
.btn,#customChooseBtn, #colorFolderModal .btn-ghost, #cancelMoveFolder, #confirmMoveFolder, #cancelRenameFolder, #submitRenameFolder, #cancelDeleteFolder, #confirmDeleteFolder, #cancelCreateFolder, #submitCreateFolder{border-radius:999px;font-weight:500;border:1px solid transparent;transition:background-color var(--filr-transition-fast),box-shadow var(--filr-transition-fast),transform var(--filr-transition-fast),border-color var(--filr-transition-fast)}
|
||||
.btn-primary,#createBtn,#uploadBtn,#submitCreateFolder,#submitRenameFolder,#confirmMoveFolder{box-shadow:0 2px 4px rgba(0,0,0,.6)}
|
||||
.btn-danger,.btn-primary,#createBtn,#uploadBtn,#submitCreateFolder,#submitRenameFolder,#confirmMoveFolder{box-shadow:0 2px 4px rgba(0,0,0,.6)}
|
||||
.btn-primary:hover,#createBtn:hover,#uploadBtn:hover,#submitCreateFolder:hover,#submitRenameFolder:hover,#confirmMoveFolder:hover{filter:brightness(1.04);transform:translateY(-1px);box-shadow:0 10px 22px rgba(0,140,180,.28)}
|
||||
.btn-danger:hover{filter:brightness(1.04);transform:translateY(-1px);box-shadow:0 10px 22px rgba(248,113,113,.35)}
|
||||
#deleteFolderBtn,#confirmDeleteFolder{border-color:rgba(248,113,113,.9);box-shadow:0 8px 18px rgba(248,113,113,.35)}
|
||||
input[type=text],input[type=password],input[type=email],input[type=url],select,textarea{border-radius:10px;padding:8px 10px;font-size:.92rem;transition:border-color var(--filr-transition-fast),box-shadow var(--filr-transition-fast),background-color var(--filr-transition-fast)}
|
||||
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--filr-accent-500);box-shadow:0 0 0 1px var(--filr-accent-ring)}
|
||||
.modal{backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}
|
||||
#fileListContainer,#uploadCard,#folderManagementCard,.card,.admin-panel-content{border-radius:var(--filr-radius-xl);border:1px solid rgba(15,23,42,.06);background:#ffffff;box-shadow:var(--filr-shadow-subtle)}
|
||||
body{min-height:100vh}
|
||||
/* === Dark Mode === */
|
||||
body.dark-mode{background:var(--fr-surface-dark-2)!important;color:#f1f1f1!important;background-image:none!important}
|
||||
body.dark-mode #fileListContainer,body.dark-mode #uploadCard,body.dark-mode #folderManagementCard,body.dark-mode .card,body.dark-mode .admin-panel-content,body.dark-mode .media-topbar{background:var(--fr-surface-dark)!important;border-color:var(--fr-border-dark)!important;box-shadow:0 1px 4px rgba(0,0,0,.9)!important;backdrop-filter:none!important;-webkit-backdrop-filter:none!important}
|
||||
body.dark-mode #fileListContainer::before,body.dark-mode #uploadCard::before,body.dark-mode #folderManagementCard::before,body.dark-mode .card::before,body.dark-mode .admin-panel-content::before{box-shadow:none!important}
|
||||
@@ -2387,6 +2406,7 @@ body.dark-mode #decreaseFont:not(:disabled):hover,body.dark-mode #increaseFont:n
|
||||
#fileList table.filr-table tbody tr.folder-row>td.folder-icon-cell{overflow:visible}
|
||||
#fileList tr.folder-row .folder-row-inner,#fileList tr.folder-row .folder-row-name{cursor:inherit}
|
||||
|
||||
/* === App Zoom Shell === */
|
||||
:root {
|
||||
--app-zoom: 1; /* 1.0 = 100% */
|
||||
}
|
||||
@@ -2856,6 +2876,7 @@ th[data-column="actions"]::after {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
/* === Late Additions: Hover Preview === */
|
||||
/* ============================================
|
||||
HOVER PREVIEW CARD – glassmorphism
|
||||
============================================ */
|
||||
@@ -3137,6 +3158,7 @@ th[data-column="actions"]::after {
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
/* === Late Additions: Admin / OIDC === */
|
||||
/* Base look for the OIDC debug JSON box */
|
||||
#oidcDebugSnapshot,
|
||||
.oidc-debug-snapshot {
|
||||
|
||||
@@ -2460,6 +2460,7 @@ export function openAdminPanel() {
|
||||
const hasSecret = !!(config.oidc && config.oidc.hasClientSecret);
|
||||
const oidcDebugEnabled = !!(config.oidc && config.oidc.debugLogging);
|
||||
const oidcAllowDemote = !!(config.oidc && config.oidc.allowDemote);
|
||||
const oidcPublicClient = !!(config.oidc && config.oidc.publicClient);
|
||||
|
||||
document.getElementById("oidcContent").innerHTML = `
|
||||
<div class="admin-subsection-title" style="margin-top:2px;">
|
||||
@@ -2500,6 +2501,21 @@ export function openAdminPanel() {
|
||||
${renderMaskedInput({ id: "oidcClientId", label: t("oidc_client_id"), hasValue: hasId })}
|
||||
${renderMaskedInput({ id: "oidcClientSecret", label: t("oidc_client_secret"), hasValue: hasSecret, isSecret: true })}
|
||||
|
||||
<div class="form-group" style="margin-top:6px;">
|
||||
<div class="form-check fr-toggle">
|
||||
<input type="checkbox"
|
||||
class="form-check-input fr-toggle-input"
|
||||
id="oidcPublicClient"
|
||||
${oidcPublicClient ? 'checked' : ''} />
|
||||
<label class="form-check-label" for="oidcPublicClient">
|
||||
${tf("oidc_public_client_label", "This is a public OIDC client (no client secret)")}
|
||||
</label>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-1">
|
||||
${tf("oidc_public_client_help", "Uses PKCE (S256) with token auth method \"none\". Leave unchecked for confidential clients that send a client secret.")}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="oidcRedirectUri">${t("oidc_redirect_uri")}:</label>
|
||||
<input type="text" id="oidcRedirectUri" class="form-control"
|
||||
@@ -3090,6 +3106,10 @@ ${t("shared_max_upload_size_bytes")}
|
||||
wireClamavTestButton(document.getElementById("uploadContent"));
|
||||
initVirusLogUI({ isPro });
|
||||
document.getElementById("oidcProviderUrl").value = window.currentOIDCConfig?.providerUrl || "";
|
||||
const publicClientChk = document.getElementById("oidcPublicClient");
|
||||
if (publicClientChk) {
|
||||
publicClientChk.checked = !!window.currentOIDCConfig?.publicClient;
|
||||
}
|
||||
const idEl = document.getElementById("oidcClientId");
|
||||
const secEl = document.getElementById("oidcClientSecret");
|
||||
if (!hasId) idEl.value = window.currentOIDCConfig?.clientId || "";
|
||||
@@ -3142,6 +3162,7 @@ function handleSave() {
|
||||
const enableBasicAuth = !!document.getElementById("enableBasicAuth")?.checked;
|
||||
const enableOIDCLogin = !!document.getElementById("enableOIDCLogin")?.checked;
|
||||
const proxyOnlyEnabled = !!document.getElementById("authBypass")?.checked;
|
||||
const oidcPublicClient = !!document.getElementById("oidcPublicClient")?.checked;
|
||||
|
||||
const authHeaderName =
|
||||
(document.getElementById("authHeaderName")?.value || "").trim() ||
|
||||
@@ -3169,6 +3190,7 @@ function handleSave() {
|
||||
.value.trim(),
|
||||
debugLogging: !!document.getElementById("oidcDebugLogging")?.checked,
|
||||
allowDemote: !!document.getElementById("oidcAllowDemote")?.checked,
|
||||
publicClient: oidcPublicClient,
|
||||
// clientId/clientSecret added conditionally below
|
||||
},
|
||||
globalOtpauthUrl: document
|
||||
@@ -3197,7 +3219,10 @@ function handleSave() {
|
||||
if ((idEl?.dataset.replace === '1' || idFirstTime) && idVal !== '') {
|
||||
payload.oidc.clientId = idVal;
|
||||
}
|
||||
if ((scEl?.dataset.replace === '1' || secFirstTime) && secVal !== '') {
|
||||
if (oidcPublicClient) {
|
||||
// Explicitly clear any stored secret when switching to public client mode
|
||||
payload.oidc.clientSecret = '';
|
||||
} else if ((scEl?.dataset.replace === '1' || secFirstTime) && secVal !== '') {
|
||||
payload.oidc.clientSecret = secVal;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,16 @@ const tf = (key, fallback) => {
|
||||
return (v && v !== key) ? v : fallback;
|
||||
};
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
bytes = Number(bytes) || 0;
|
||||
if (bytes <= 0) return '0 B';
|
||||
@@ -36,6 +46,18 @@ function getCsrfToken() {
|
||||
return (document.querySelector('meta[name="csrf-token"]')?.content || '');
|
||||
}
|
||||
|
||||
function getDefaultScanHint(isPro) {
|
||||
return isPro
|
||||
? tf(
|
||||
'storage_rescan_hint_pro',
|
||||
'Run a fresh disk usage snapshot when storage changes.'
|
||||
)
|
||||
: tf(
|
||||
'storage_rescan_cli_hint',
|
||||
'Click Rescan to run a snapshot now, or schedule the CLI scanner via cron.'
|
||||
);
|
||||
}
|
||||
|
||||
let confirmModalEl = null;
|
||||
let showAllTopFolders = false;
|
||||
|
||||
@@ -118,6 +140,9 @@ function showConfirmDialog({ title, message, confirmLabel }) {
|
||||
// snapshot / scanning
|
||||
let lastGeneratedAt = 0;
|
||||
let scanPollTimer = null;
|
||||
let lastScanLogPath = '';
|
||||
let lastScanLogMtime = null;
|
||||
let defaultScanHint = '';
|
||||
|
||||
// Pro-only dangerous mode: deep delete for folders
|
||||
let deepDeleteEnabled = false;
|
||||
@@ -156,6 +181,30 @@ function setScanStatus(isScanning) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderScanProblem({ message, logTail, logPath }) {
|
||||
const statusEl = document.getElementById('adminStorageScanStatus');
|
||||
if (!statusEl) return;
|
||||
|
||||
const tailHtml = logTail
|
||||
? `<pre class="small bg-light p-2 rounded border mb-1" style="user-select:text; white-space:pre-wrap;">${escapeHtml(logTail)}</pre>`
|
||||
: '';
|
||||
|
||||
const pathHtml = logPath
|
||||
? `<div class="small text-muted">${tf('storage_scan_log_path', 'Log file')}: <code>${escapeHtml(logPath)}</code></div>`
|
||||
: '';
|
||||
|
||||
statusEl.innerHTML = `
|
||||
<div class="alert alert-warning mb-2" style="border-radius: var(--menu-radius);">
|
||||
<div class="fw-semibold mb-1">
|
||||
${tf('storage_scan_failed', 'Disk usage scan did not finish.')}
|
||||
</div>
|
||||
${message ? `<div class="small mb-1">${escapeHtml(message)}</div>` : ''}
|
||||
${tailHtml}
|
||||
${pathHtml}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Make sure delete buttons visually reflect whether deep delete is enabled
|
||||
function updateDeleteButtonsForDeepDelete() {
|
||||
const host = document.getElementById('adminStorageProTeaser');
|
||||
@@ -201,7 +250,7 @@ function updateDeleteButtonsForDeepDelete() {
|
||||
function renderBaseLayout(container, { isPro }) {
|
||||
container.innerHTML = `
|
||||
<div class="storage-section mt-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center gap-2 mb-2">
|
||||
<div>
|
||||
<h5 class="mb-1">${tf('storage_disk_usage', 'Storage / Disk Usage')}</h5>
|
||||
<small class="text-muted">
|
||||
@@ -220,20 +269,17 @@ function renderBaseLayout(container, { isPro }) {
|
||||
<i class="material-icons" style="vertical-align:middle;font-size:18px;color:currentColor;">refresh</i>
|
||||
<span style="vertical-align:middle;">${tf('rescan_now', 'Rescan')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="adminStorageDeleteSnapshot"
|
||||
class="btn btn-sm btn-danger">
|
||||
<i class="material-icons" style="vertical-align:middle;font-size:18px;color:currentColor;">delete_forever</i>
|
||||
<span style="vertical-align:middle;">${tf('storage_delete_snapshot','Delete snapshot')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<small class="text-muted d-block" id="adminStorageScanHint">
|
||||
${
|
||||
isPro
|
||||
? tf(
|
||||
'storage_rescan_hint_pro',
|
||||
'Run a fresh disk usage snapshot when storage changes.'
|
||||
)
|
||||
: tf(
|
||||
'storage_rescan_cli_hint',
|
||||
'Click Rescan to run a snapshot now, or schedule the CLI scanner via cron.'
|
||||
)
|
||||
}
|
||||
${getDefaultScanHint(isPro)}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -345,7 +391,28 @@ async function refreshStorageSummary() {
|
||||
summaryEl.innerHTML = `<div class="text-muted">${tf('loading', 'Loading...')}</div>`;
|
||||
|
||||
const data = await fetchSummaryRaw();
|
||||
const logInfo = data && typeof data === 'object' ? data.scanLog : null;
|
||||
if (logInfo && logInfo.path) {
|
||||
lastScanLogPath = logInfo.path;
|
||||
}
|
||||
if (logInfo && typeof logInfo.modifiedAt === 'number' && logInfo.modifiedAt > 0) {
|
||||
lastScanLogMtime = logInfo.modifiedAt;
|
||||
}
|
||||
|
||||
if (!data || !data.ok) {
|
||||
const reason = data && data.message
|
||||
? String(data.message)
|
||||
: data && data.error
|
||||
? String(data.error)
|
||||
: '';
|
||||
const logPath = (logInfo && logInfo.path) || lastScanLogPath || '';
|
||||
const tailHtml = logInfo && logInfo.tail
|
||||
? `<pre class="small bg-light p-2 rounded border mt-2" style="user-select:text; white-space:pre-wrap;">${escapeHtml(logInfo.tail)}</pre>`
|
||||
: '';
|
||||
const pathHtml = logPath
|
||||
? `<div class="small text-muted mt-1">${tf('storage_scan_log_path','Log file')}: <code>${escapeHtml(logPath)}</code></div>`
|
||||
: '';
|
||||
|
||||
if (data && data.error === 'no_snapshot') {
|
||||
const cmd = 'php src/cli/disk_usage_scan.php';
|
||||
summaryEl.innerHTML = `
|
||||
@@ -354,19 +421,31 @@ async function refreshStorageSummary() {
|
||||
'storage_no_snapshot',
|
||||
'No disk usage snapshot found. Run the CLI scanner once to generate the first snapshot.'
|
||||
)}
|
||||
${pathHtml}
|
||||
</div>
|
||||
<pre class="small bg-light p-2 rounded border" style="user-select:text; white-space:pre-wrap;">
|
||||
${cmd}
|
||||
</pre>
|
||||
${tailHtml}
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
summaryEl.innerHTML = `
|
||||
<div class="text-danger">
|
||||
${tf('storage_summary_error', 'Unable to load disk usage summary.')}
|
||||
<div class="alert alert-danger mb-2" style="border-radius: var(--menu-radius);">
|
||||
<div class="fw-semibold">${tf('storage_summary_error', 'Unable to load disk usage summary.')}</div>
|
||||
${reason ? `<div class="small">${escapeHtml(reason)}</div>` : ''}
|
||||
${pathHtml}
|
||||
${tailHtml}
|
||||
</div>
|
||||
`;
|
||||
if (logInfo && logInfo.tail) {
|
||||
renderScanProblem({
|
||||
message: reason || tf('storage_summary_error', 'Unable to load disk usage summary.'),
|
||||
logTail: logInfo.tail,
|
||||
logPath
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -648,7 +727,7 @@ const displayTopFolders = (isProGlobal && showAllTopFolders)
|
||||
* We don't know real progress %, but as soon as generatedAt increases
|
||||
* we refresh the summary and stop polling.
|
||||
*/
|
||||
function startScanPolling(initialGeneratedAt) {
|
||||
function startScanPolling(initialGeneratedAt, { logPath = '', logMtime = null } = {}) {
|
||||
if (scanPollTimer) {
|
||||
clearInterval(scanPollTimer);
|
||||
scanPollTimer = null;
|
||||
@@ -657,22 +736,76 @@ function startScanPolling(initialGeneratedAt) {
|
||||
setScanStatus(true);
|
||||
const startTime = Date.now();
|
||||
const maxMs = 10 * 60 * 1000; // 10 minutes safety
|
||||
let lastSeenLogMtime = logMtime ?? null;
|
||||
let lastSeenLogTail = '';
|
||||
let lastSeenErrorMsg = '';
|
||||
let lastSeenLogPath = logPath || '';
|
||||
const stallWarnMs = 60 * 1000; // warn if nothing moves after 60s
|
||||
|
||||
scanPollTimer = window.setInterval(async () => {
|
||||
if (Date.now() - startTime > maxMs) {
|
||||
clearInterval(scanPollTimer);
|
||||
scanPollTimer = null;
|
||||
setScanStatus(false);
|
||||
renderScanProblem({
|
||||
message: lastSeenErrorMsg || tf('storage_scan_timeout', 'Disk usage scan did not finish within 10 minutes. See the scan log for details.'),
|
||||
logTail: lastSeenLogTail,
|
||||
logPath: lastSeenLogPath || lastScanLogPath
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await fetchSummaryRaw();
|
||||
if (!data || !data.ok) {
|
||||
if (!data) return;
|
||||
|
||||
const logInfo = data.scanLog || null;
|
||||
const gen = data.generatedAt || 0;
|
||||
|
||||
if (logInfo) {
|
||||
if (logInfo.path) {
|
||||
lastSeenLogPath = logInfo.path;
|
||||
lastScanLogPath = logInfo.path;
|
||||
}
|
||||
if (typeof logInfo.modifiedAt === 'number' && logInfo.modifiedAt > 0) {
|
||||
if (lastSeenLogMtime === null) {
|
||||
lastSeenLogMtime = logInfo.modifiedAt;
|
||||
} else if (logInfo.hasError && logInfo.modifiedAt > lastSeenLogMtime && (!gen || gen <= initialGeneratedAt)) {
|
||||
clearInterval(scanPollTimer);
|
||||
scanPollTimer = null;
|
||||
setScanStatus(false);
|
||||
renderScanProblem({
|
||||
message: lastSeenErrorMsg || (logInfo.tail ? logInfo.tail.split('\n').pop() : ''),
|
||||
logTail: logInfo.tail || lastSeenLogTail,
|
||||
logPath: lastSeenLogPath
|
||||
});
|
||||
return;
|
||||
}
|
||||
lastSeenLogMtime = logInfo.modifiedAt;
|
||||
}
|
||||
if (logInfo.tail) {
|
||||
lastSeenLogTail = logInfo.tail;
|
||||
}
|
||||
}
|
||||
|
||||
if (!data.ok) {
|
||||
if (data.message) {
|
||||
lastSeenErrorMsg = String(data.message);
|
||||
}
|
||||
if (!lastSeenLogMtime && Date.now() - startTime > stallWarnMs) {
|
||||
// No snapshot change and no log activity: likely worker not running or empty
|
||||
clearInterval(scanPollTimer);
|
||||
scanPollTimer = null;
|
||||
setScanStatus(false);
|
||||
renderScanProblem({
|
||||
message: tf('storage_scan_no_progress', 'Disk usage scan did not produce a snapshot yet. Verify the CLI worker (src/cli/disk_usage_scan.php) and PHP CLI path.'),
|
||||
logTail: lastSeenLogTail,
|
||||
logPath: lastSeenLogPath || lastScanLogPath
|
||||
});
|
||||
}
|
||||
// still no snapshot / error, keep waiting
|
||||
return;
|
||||
}
|
||||
|
||||
const gen = data.generatedAt || 0;
|
||||
if (gen && gen > initialGeneratedAt) {
|
||||
clearInterval(scanPollTimer);
|
||||
scanPollTimer = null;
|
||||
@@ -681,6 +814,18 @@ function startScanPolling(initialGeneratedAt) {
|
||||
await refreshStorageSummary();
|
||||
setScanStatus(false);
|
||||
showToast(tf('storage_scan_complete', 'Disk usage scan completed.'));
|
||||
// refresh explorer data if visible
|
||||
if (isProGlobal) {
|
||||
if (currentExplorerTab === 'folders') {
|
||||
loadFolderChildren(currentFolderKey);
|
||||
} else {
|
||||
loadTopFiles();
|
||||
}
|
||||
}
|
||||
const hintEl = document.getElementById('adminStorageScanHint');
|
||||
if (hintEl) {
|
||||
hintEl.textContent = defaultScanHint || getDefaultScanHint(isProGlobal);
|
||||
}
|
||||
}
|
||||
}, 4000);
|
||||
}
|
||||
@@ -691,6 +836,7 @@ function startScanPolling(initialGeneratedAt) {
|
||||
function wireRescan(/* isPro */) {
|
||||
const btn = document.getElementById('adminStorageRescan');
|
||||
const hintEl = document.getElementById('adminStorageScanHint');
|
||||
const deleteBtn = document.getElementById('adminStorageDeleteSnapshot');
|
||||
if (!btn) return;
|
||||
|
||||
if (btn.dataset.wired === '1') return;
|
||||
@@ -719,13 +865,18 @@ function wireRescan(/* isPro */) {
|
||||
showToast(
|
||||
tf('storage_rescan_started', 'Disk usage scan started in the background.')
|
||||
);
|
||||
lastScanLogPath = payload.logFile || '';
|
||||
lastScanLogMtime = payload.logMtime || null;
|
||||
if (hintEl) {
|
||||
hintEl.textContent = tf(
|
||||
'storage_rescan_hint_with_log',
|
||||
'Scan is running in the background. The summary will update when it finishes.'
|
||||
);
|
||||
}
|
||||
startScanPolling(initialGenerated);
|
||||
startScanPolling(initialGenerated, {
|
||||
logPath: lastScanLogPath,
|
||||
logMtime: lastScanLogMtime
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Rescan error', err);
|
||||
@@ -738,6 +889,59 @@ function wireRescan(/* isPro */) {
|
||||
btn.innerHTML = oldHtml;
|
||||
}
|
||||
});
|
||||
|
||||
if (deleteBtn && !deleteBtn.dataset.wired) {
|
||||
deleteBtn.dataset.wired = '1';
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
const ok = await showConfirmDialog({
|
||||
title: tf('storage_delete_snapshot','Delete snapshot'),
|
||||
message: tf(
|
||||
'storage_delete_snapshot_confirm',
|
||||
'Delete the current disk usage snapshot? The Storage view will be empty until a new scan finishes.'
|
||||
),
|
||||
confirmLabel: tf('delete','Delete')
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
deleteBtn.disabled = true;
|
||||
const old = deleteBtn.innerHTML;
|
||||
deleteBtn.innerHTML = `
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span class="ms-1">${tf('deleting','Deleting...')}</span>
|
||||
`;
|
||||
|
||||
try {
|
||||
const resp = await sendRequest('/api/admin/diskUsageDeleteSnapshot.php', 'POST', null, {
|
||||
'X-CSRF-Token': getCsrfToken()
|
||||
});
|
||||
if (!resp || resp.ok !== true) {
|
||||
showToast(tf('storage_delete_snapshot_failed','Failed to delete snapshot. See logs.'));
|
||||
} else {
|
||||
lastGeneratedAt = 0;
|
||||
lastScanLogMtime = null;
|
||||
refreshStorageSummary();
|
||||
if (isProGlobal) {
|
||||
if (currentExplorerTab === 'folders') {
|
||||
loadFolderChildren('root');
|
||||
} else {
|
||||
loadTopFiles();
|
||||
}
|
||||
}
|
||||
showToast(tf('storage_delete_snapshot_ok','Snapshot deleted. Run Rescan to build a new one.'));
|
||||
const hintEl = document.getElementById('adminStorageScanHint');
|
||||
if (hintEl) {
|
||||
hintEl.textContent = defaultScanHint || getDefaultScanHint(isProGlobal);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Delete snapshot error', e);
|
||||
showToast(tf('storage_delete_snapshot_failed','Failed to delete snapshot. See logs.'));
|
||||
} finally {
|
||||
deleteBtn.disabled = false;
|
||||
deleteBtn.innerHTML = old;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Pro Explorer (ncdu-style) ----------
|
||||
@@ -1655,6 +1859,7 @@ export function initAdminStorageSection({ isPro, modalEl }) {
|
||||
if (!container) return;
|
||||
|
||||
isProGlobal = !!isPro;
|
||||
defaultScanHint = getDefaultScanHint(isProGlobal);
|
||||
|
||||
// Make it safe to call multiple times
|
||||
if (!container.dataset.inited) {
|
||||
@@ -1681,4 +1886,4 @@ export function initAdminStorageSection({ isPro, modalEl }) {
|
||||
refreshStorageSummary();
|
||||
wireRescan(isProGlobal);
|
||||
setScanStatus(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ const ZONES = {
|
||||
};
|
||||
const LAYOUT_KEY = 'userZonesSnapshot.v2'; // {cardId: zoneId}
|
||||
const RESPONSIVE_STASH_KEY = 'responsiveSidebarSnapshot.v2'; // [cardId]
|
||||
const ORDER_DATA_KEY = '__zoneOrder';
|
||||
const ORDER_TRACKED_ZONES = [ZONES.SIDEBAR];
|
||||
|
||||
// -------------------- small helpers --------------------
|
||||
function $(id) { return document.getElementById(id); }
|
||||
@@ -43,7 +45,16 @@ function writeLayout(layout) {
|
||||
}
|
||||
function setLayoutFor(cardId, zoneId) {
|
||||
const layout = readLayout();
|
||||
const prevZone = layout[cardId];
|
||||
layout[cardId] = zoneId;
|
||||
if (ORDER_TRACKED_ZONES.includes(zoneId)) captureZoneOrder(layout, zoneId);
|
||||
if (
|
||||
prevZone &&
|
||||
prevZone !== zoneId &&
|
||||
ORDER_TRACKED_ZONES.includes(prevZone)
|
||||
) {
|
||||
captureZoneOrder(layout, prevZone);
|
||||
}
|
||||
writeLayout(layout);
|
||||
}
|
||||
|
||||
@@ -309,9 +320,44 @@ function saveCurrentLayout() {
|
||||
const zone = currentZoneForCard(card);
|
||||
if (zone) layout[card.id] = zone;
|
||||
});
|
||||
ORDER_TRACKED_ZONES.forEach(zoneId => captureZoneOrder(layout, zoneId));
|
||||
writeLayout(layout);
|
||||
}
|
||||
|
||||
function getZoneOrder(layout, zoneId) {
|
||||
if (!layout || typeof layout !== 'object') return [];
|
||||
const store = layout[ORDER_DATA_KEY];
|
||||
if (!store || !Array.isArray(store[zoneId])) return [];
|
||||
return store[zoneId];
|
||||
}
|
||||
|
||||
function captureZoneOrder(layout, zoneId) {
|
||||
if (!layout || !ORDER_TRACKED_ZONES.includes(zoneId)) return;
|
||||
const host = getZoneHost(zoneId);
|
||||
if (!host) return;
|
||||
const ids = Array.from(
|
||||
host.querySelectorAll('#uploadCard, #folderManagementCard')
|
||||
).map(el => el.id).filter(Boolean);
|
||||
|
||||
if (!ids.length) return;
|
||||
|
||||
layout[ORDER_DATA_KEY] = layout[ORDER_DATA_KEY] || {};
|
||||
layout[ORDER_DATA_KEY][zoneId] = ids;
|
||||
}
|
||||
|
||||
function applyZoneOrder(layout, zoneId, placedSet) {
|
||||
if (!placedSet) return;
|
||||
const ids = getZoneOrder(layout, zoneId);
|
||||
if (!ids.length) return;
|
||||
ids.forEach(cardId => {
|
||||
if (placedSet.has(cardId)) return;
|
||||
const card = $(cardId);
|
||||
if (!card || layout[cardId] !== zoneId) return;
|
||||
placeCardInZone(card, zoneId, { animate: false });
|
||||
placedSet.add(cardId);
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------- responsive stash --------------------
|
||||
function stashSidebarCardsBeforeSmall() {
|
||||
const sb = getSidebar();
|
||||
@@ -384,6 +430,10 @@ function hideHeaderDockPersistent() {
|
||||
}
|
||||
}
|
||||
|
||||
const COLLAPSE_ANIMATION_MS = 420;
|
||||
const COLLAPSE_TARGET_SCALE = 0.45;
|
||||
const COLLAPSE_OPACITY_END = 0.2;
|
||||
|
||||
function animateCardsIntoHeaderAndThen(done) {
|
||||
const sb = getSidebar();
|
||||
const top = getTopZone();
|
||||
@@ -414,9 +464,13 @@ function animateCardsIntoHeaderAndThen(done) {
|
||||
const ghosts = [];
|
||||
|
||||
snapshots.forEach(({ card, rect }) => {
|
||||
// remember the size for the expand animation later
|
||||
// remember the size and center for the expand animation later
|
||||
card.dataset.lastWidth = String(rect.width);
|
||||
card.dataset.lastHeight = String(rect.height);
|
||||
card.dataset.lastTargetLeft = String(rect.left);
|
||||
card.dataset.lastTargetTop = String(rect.top);
|
||||
card.dataset.lastTargetCx = String(rect.left + rect.width / 2);
|
||||
card.dataset.lastTargetCy = String(rect.top + rect.height / 2);
|
||||
|
||||
const iconBtn = card.headerIconButton;
|
||||
if (!iconBtn) return;
|
||||
@@ -426,7 +480,7 @@ function animateCardsIntoHeaderAndThen(done) {
|
||||
const ghost = createCardGhost(card, rect, { scale: 1, opacity: 0.95 });
|
||||
ghost.id = card.id + '-ghost-collapse';
|
||||
ghost.classList.add('card-collapse-ghost');
|
||||
ghost.style.transition = 'transform 0.4s cubic-bezier(.22,.61,.36,1), opacity 0.4s linear';
|
||||
ghost.style.transition = 'transform 0.42s cubic-bezier(.33,.1,.25,1), opacity 0.32s linear';
|
||||
|
||||
document.body.appendChild(ghost);
|
||||
ghosts.push({ ghost, from: rect, to: iconRect });
|
||||
@@ -444,23 +498,17 @@ function animateCardsIntoHeaderAndThen(done) {
|
||||
const fromCy = from.top + from.height / 2;
|
||||
const toCx = to.left + to.width / 2;
|
||||
const toCy = to.top + to.height / 2;
|
||||
|
||||
const dx = toCx - fromCx;
|
||||
const dy = toCy - fromCy;
|
||||
|
||||
const rawScale = to.width / from.width;
|
||||
const scale = Math.max(0.35, Math.min(0.6, rawScale * 0.9));
|
||||
|
||||
// ✨ more readable: clear slide + shrink, but don’t fully vanish mid-flight
|
||||
ghost.style.transform = `translate(${dx}px, ${dy}px) scale(${scale})`;
|
||||
ghost.style.opacity = '0.35';
|
||||
ghost.style.transform = `translate(${dx}px, ${dy}px) scale(${COLLAPSE_TARGET_SCALE})`;
|
||||
ghost.style.opacity = String(COLLAPSE_OPACITY_END);
|
||||
});
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
ghosts.forEach(({ ghost }) => { try { ghost.remove(); } catch {} });
|
||||
done();
|
||||
}, 430); // a bit over the 0.4s transition
|
||||
}, COLLAPSE_ANIMATION_MS + 50);
|
||||
}
|
||||
|
||||
function resolveTargetZoneForExpand(cardId) {
|
||||
@@ -502,6 +550,7 @@ function getZoneHost(zoneId) {
|
||||
}
|
||||
}
|
||||
|
||||
const EXPAND_START_SCALE = 0.94;
|
||||
|
||||
// Animate cards "flying out" of header icons back into their zones.
|
||||
function animateCardsOutOfHeaderThen(done) {
|
||||
@@ -518,48 +567,51 @@ function animateCardsOutOfHeaderThen(done) {
|
||||
if (top) top.style.display = '';
|
||||
|
||||
const SAFE_TOP = 16;
|
||||
const START_OFFSET_Y = 32; // a touch closer to header
|
||||
const START_OFFSET_Y = 95; // a touch closer to header
|
||||
const DEST_EXTRA_Y = 120;
|
||||
|
||||
const ghosts = [];
|
||||
const layout = readLayout();
|
||||
const plan = [];
|
||||
|
||||
cards.forEach(card => {
|
||||
const iconBtn = card.headerIconButton;
|
||||
if (!iconBtn) return;
|
||||
|
||||
const zoneId = resolveTargetZoneForExpand(card.id);
|
||||
if (!zoneId) return; // header-only card, stays as icon
|
||||
if (!zoneId) return;
|
||||
plan.push({ card, iconBtn, zoneId });
|
||||
});
|
||||
|
||||
if (!plan.length) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
const savedHeights = {};
|
||||
CARD_IDS.forEach(id => {
|
||||
const val = parseFloat($(id)?.dataset.lastHeight || '');
|
||||
savedHeights[id] = (!Number.isNaN(val) && val > 0) ? val : 190;
|
||||
});
|
||||
|
||||
const sidebarOrder = getZoneOrder(layout, ZONES.SIDEBAR);
|
||||
const fallbackSidebarOrder = sidebarOrder.length ? sidebarOrder : CARD_IDS;
|
||||
const ghosts = [];
|
||||
|
||||
plan.forEach(({ card, iconBtn, zoneId }) => {
|
||||
const host = getZoneHost(zoneId);
|
||||
if (!host) return;
|
||||
|
||||
const iconRect = iconBtn.getBoundingClientRect();
|
||||
const zoneRect = host.getBoundingClientRect();
|
||||
if (!zoneRect.width) return;
|
||||
|
||||
const iconRect = iconBtn.getBoundingClientRect();
|
||||
const fromCx = iconRect.left + iconRect.width / 2;
|
||||
const fromCy = iconRect.bottom + START_OFFSET_Y;
|
||||
|
||||
let toCx = zoneRect.left + zoneRect.width / 2;
|
||||
let toCy = zoneRect.top + Math.min(zoneRect.height / 2 || DEST_EXTRA_Y, DEST_EXTRA_Y);
|
||||
|
||||
if (zoneId === ZONES.SIDEBAR) {
|
||||
if (card.id === 'uploadCard') {
|
||||
toCy -= 48;
|
||||
} else if (card.id === 'folderManagementCard') {
|
||||
toCy += 48;
|
||||
}
|
||||
}
|
||||
|
||||
const savedW = parseFloat(card.dataset.lastWidth || '');
|
||||
const savedH = parseFloat(card.dataset.lastHeight || '');
|
||||
const targetWidth = !Number.isNaN(savedW)
|
||||
? savedW
|
||||
: Math.min(280, Math.max(220, zoneRect.width * 0.85));
|
||||
const targetHeight = !Number.isNaN(savedH) ? savedH : 190;
|
||||
const targetWidth = (!Number.isNaN(savedW) && savedW > 0) ? savedW : Math.min(280, Math.max(220, zoneRect.width * 0.85));
|
||||
const targetHeight = (!Number.isNaN(savedH) && savedH > 0) ? savedH : 190;
|
||||
|
||||
const startTop = Math.max(SAFE_TOP, fromCy - targetHeight / 2);
|
||||
|
||||
const ghostRect = {
|
||||
left: fromCx - targetWidth / 2,
|
||||
top: startTop,
|
||||
@@ -567,19 +619,49 @@ function animateCardsOutOfHeaderThen(done) {
|
||||
height: targetHeight
|
||||
};
|
||||
|
||||
const ghost = createCardGhost(card, ghostRect, { scale: 0.75, opacity: 0.25 });
|
||||
const ghost = createCardGhost(card, ghostRect, { scale: EXPAND_START_SCALE, opacity: 0.55 });
|
||||
ghost.id = card.id + '-ghost-expand';
|
||||
ghost.classList.add('card-expand-ghost');
|
||||
|
||||
ghost.style.transform = 'translate(0,0) scale(0.75)';
|
||||
ghost.style.transform = `translate(0,0) scale(${EXPAND_START_SCALE})`;
|
||||
ghost.style.transition = 'transform 0.4s cubic-bezier(.22,.61,.36,1), opacity 0.4s linear';
|
||||
|
||||
document.body.appendChild(ghost);
|
||||
|
||||
const savedCx = parseFloat(card.dataset.lastTargetCx || '');
|
||||
const savedCy = parseFloat(card.dataset.lastTargetCy || '');
|
||||
const hasSavedCenter = !Number.isNaN(savedCx) && !Number.isNaN(savedCy);
|
||||
const fallbackCenter = (() => {
|
||||
const normalizedZoneHeight = zoneRect.height || DEST_EXTRA_Y;
|
||||
const baseTop = zoneRect.top + 16;
|
||||
const gap = 10;
|
||||
let toCy;
|
||||
if (zoneId === ZONES.SIDEBAR) {
|
||||
const stack = fallbackSidebarOrder;
|
||||
const idx = stack.indexOf(card.id);
|
||||
const resolvedIndex = idx >= 0 ? idx : ((card.id === 'uploadCard') ? 0 : 1);
|
||||
const precedingHeight = stack
|
||||
.slice(0, resolvedIndex)
|
||||
.reduce((sum, id) => sum + (savedHeights[id] || 190) + gap, 0);
|
||||
const cardHeight = savedHeights[card.id] ?? targetHeight;
|
||||
const rowTop = baseTop + precedingHeight;
|
||||
toCy = rowTop + cardHeight / 2 - 8;
|
||||
} else {
|
||||
toCy = zoneRect.top + Math.min(normalizedZoneHeight / 2 || DEST_EXTRA_Y, DEST_EXTRA_Y);
|
||||
}
|
||||
const toCx = zoneRect.left + zoneRect.width / 2;
|
||||
const drift = (zoneId === ZONES.SIDEBAR) ? (card.id === 'uploadCard' ? -8 : 8) : 0;
|
||||
return { cx: toCx, cy: toCy + drift };
|
||||
})();
|
||||
|
||||
const targetCenter = hasSavedCenter
|
||||
? { cx: savedCx, cy: savedCy }
|
||||
: fallbackCenter;
|
||||
|
||||
ghosts.push({
|
||||
ghost,
|
||||
from: { cx: fromCx, cy: fromCy },
|
||||
to: { cx: toCx, cy: toCy },
|
||||
zoneId
|
||||
to: targetCenter
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1075,9 +1157,15 @@ function applyUserLayoutOrDefault() {
|
||||
const hasAny = Object.keys(layout).length > 0;
|
||||
|
||||
if (hasAny) {
|
||||
const placed = new Set();
|
||||
if (!isSmallScreen()) {
|
||||
ORDER_TRACKED_ZONES.forEach(zoneId => applyZoneOrder(layout, zoneId, placed));
|
||||
}
|
||||
|
||||
getCards().forEach(card => {
|
||||
const targetZone = layout[card.id];
|
||||
if (!targetZone) return;
|
||||
if (placed.has(card.id)) return;
|
||||
// On small screens: if saved zone is the sidebar, temporarily place in top cols
|
||||
if (isSmallScreen() && targetZone === ZONES.SIDEBAR) {
|
||||
const target = (card.id === 'uploadCard') ? ZONES.TOP_LEFT : ZONES.TOP_RIGHT;
|
||||
@@ -1164,4 +1252,4 @@ export function initDragAndDrop() {
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +198,7 @@ class AdminController
|
||||
'hasClientSecret' => !empty($config['oidc']['clientSecret']),
|
||||
'debugLogging' => !empty($config['oidc']['debugLogging']),
|
||||
'allowDemote' => !empty($config['oidc']['allowDemote']),
|
||||
'publicClient' => !empty($config['oidc']['publicClient']),
|
||||
]),
|
||||
'onlyoffice' => [
|
||||
'enabled' => $effEnabled,
|
||||
@@ -1140,7 +1141,8 @@ class AdminController
|
||||
'providerUrl' => '',
|
||||
'clientId' => '',
|
||||
'clientSecret' => '',
|
||||
'redirectUri' => ''
|
||||
'redirectUri' => '',
|
||||
'publicClient' => false,
|
||||
],
|
||||
'branding' => [
|
||||
'customLogoUrl' => '',
|
||||
@@ -1223,6 +1225,16 @@ class AdminController
|
||||
}
|
||||
}
|
||||
|
||||
// OIDC public client flag (and optional secret wipe)
|
||||
if (array_key_exists('publicClient', $data['oidc'])) {
|
||||
$isPublic = filter_var($data['oidc']['publicClient'], FILTER_VALIDATE_BOOLEAN);
|
||||
$merged['oidc']['publicClient'] = $isPublic;
|
||||
if ($isPublic) {
|
||||
// Ensure secret is cleared when switching to public client mode
|
||||
$merged['oidc']['clientSecret'] = '';
|
||||
}
|
||||
}
|
||||
|
||||
// OIDC debug logging toggle
|
||||
if (isset($data['oidc']['debugLogging'])) {
|
||||
$merged['oidc']['debugLogging'] = filter_var(
|
||||
|
||||
@@ -100,9 +100,13 @@ class AuthController
|
||||
$cfg = AdminModel::getConfig();
|
||||
$clientId = $cfg['oidc']['clientId'] ?? null;
|
||||
$clientSecret = $cfg['oidc']['clientSecret'] ?? null;
|
||||
$publicClient = !empty($cfg['oidc']['publicClient']);
|
||||
|
||||
// When configured as a public client (no secret), pass null, not an empty string.
|
||||
if ($clientSecret === '') {
|
||||
if (is_string($clientSecret)) {
|
||||
$clientSecret = trim($clientSecret);
|
||||
}
|
||||
if ($clientSecret === '' || $publicClient) {
|
||||
$clientSecret = null;
|
||||
}
|
||||
|
||||
@@ -110,12 +114,17 @@ class AuthController
|
||||
if (defined('OIDC_TOKEN_ENDPOINT_AUTH_METHOD') && OIDC_TOKEN_ENDPOINT_AUTH_METHOD) {
|
||||
$tokenAuthMethod = OIDC_TOKEN_ENDPOINT_AUTH_METHOD;
|
||||
}
|
||||
// Default token endpoint auth: none for public clients, basic for confidential.
|
||||
if (!$tokenAuthMethod) {
|
||||
$tokenAuthMethod = $clientSecret ? 'client_secret_basic' : 'none';
|
||||
}
|
||||
|
||||
$this->logOidcDebug('Building OIDC client', [
|
||||
'providerUrl' => $cfg['oidc']['providerUrl'] ?? null,
|
||||
'redirectUri' => $cfg['oidc']['redirectUri'] ?? null,
|
||||
'clientId' => $clientId,
|
||||
'hasClientSecret' => $clientSecret ? 'yes' : 'no',
|
||||
'publicClient' => $publicClient ? 'yes' : 'no',
|
||||
'tokenEndpointAuthMethod'=> $tokenAuthMethod ?: '(library default)',
|
||||
]);
|
||||
|
||||
@@ -699,4 +708,4 @@ foreach ($normalizedTags as $tag) {
|
||||
header("Location: /index.html?logout=1");
|
||||
exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,12 +236,25 @@ class AdminModel
|
||||
|
||||
if (!$oidcDisabled) {
|
||||
$oidc = $configUpdate['oidc'] ?? [];
|
||||
$required = ['providerUrl', 'clientId', 'clientSecret', 'redirectUri'];
|
||||
$publicClient = !empty($oidc['publicClient']);
|
||||
$required = ['providerUrl', 'clientId', 'redirectUri'];
|
||||
|
||||
// Confidential clients still require a secret
|
||||
if (!$publicClient) {
|
||||
$required[] = 'clientSecret';
|
||||
}
|
||||
|
||||
foreach ($required as $k) {
|
||||
if (empty($oidc[$k]) || !is_string($oidc[$k])) {
|
||||
return ["error" => "Incomplete OIDC configuration (enable OIDC requires providerUrl, clientId, clientSecret, redirectUri)."];
|
||||
return ["error" => "Incomplete OIDC configuration (enable OIDC requires providerUrl, clientId, redirectUri" . ($publicClient ? '' : ', clientSecret') . ")."];
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize secret handling for public clients (strip it when flagged)
|
||||
if ($publicClient) {
|
||||
$configUpdate['oidc']['clientSecret'] = '';
|
||||
}
|
||||
$configUpdate['oidc']['publicClient'] = $publicClient;
|
||||
}
|
||||
|
||||
// Ensure enableWebDAV flag is boolean (default to false if missing)
|
||||
@@ -444,8 +457,9 @@ class AdminModel
|
||||
'clientId' => '',
|
||||
'clientSecret' => '',
|
||||
'redirectUri' => '',
|
||||
'debugLogging' => false,
|
||||
'allowDemote' => false,
|
||||
'debugLogging' => false,
|
||||
'allowDemote' => false,
|
||||
'publicClient' => false,
|
||||
];
|
||||
} else {
|
||||
foreach (['providerUrl', 'clientId', 'clientSecret', 'redirectUri'] as $k) {
|
||||
@@ -453,6 +467,11 @@ class AdminModel
|
||||
$config['oidc'][$k] = '';
|
||||
}
|
||||
}
|
||||
if (!array_key_exists('publicClient', $config['oidc'])) {
|
||||
$config['oidc']['publicClient'] = false;
|
||||
} else {
|
||||
$config['oidc']['publicClient'] = !empty($config['oidc']['publicClient']);
|
||||
}
|
||||
}
|
||||
|
||||
$config['oidc']['debugLogging'] = !empty($config['oidc']['debugLogging']);
|
||||
@@ -544,7 +563,8 @@ class AdminModel
|
||||
'clientSecret' => '',
|
||||
'redirectUri' => 'https://yourdomain.com/api/auth/auth.php?oidc=callback',
|
||||
'debugLogging' => false,
|
||||
'allowDemote' => false,
|
||||
'allowDemote' => false,
|
||||
'publicClient' => false,
|
||||
],
|
||||
'loginOptions' => [
|
||||
'disableFormLogin' => false,
|
||||
|
||||
@@ -30,6 +30,70 @@ class DiskUsageModel
|
||||
/** Maximum number of per-file records to keep (for Top N view). */
|
||||
private const TOP_FILE_LIMIT = 1000;
|
||||
|
||||
/**
|
||||
* Location of the background scan log file.
|
||||
*/
|
||||
public static function scanLogPath(): string
|
||||
{
|
||||
$meta = rtrim((string)META_DIR, '/\\');
|
||||
$logDir = $meta . DIRECTORY_SEPARATOR . 'logs';
|
||||
if (!is_dir($logDir)) {
|
||||
@mkdir($logDir, 0775, true);
|
||||
}
|
||||
return $logDir . DIRECTORY_SEPARATOR . 'disk_usage_scan.log';
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the tail of the scan log to surface recent failures in the UI.
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public static function readScanLogTail(int $maxBytes = 4000): ?array
|
||||
{
|
||||
$path = self::scanLogPath();
|
||||
if (!is_file($path) || !is_readable($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$size = @filesize($path);
|
||||
$fp = @fopen($path, 'rb');
|
||||
if (!$fp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($size !== false && $size > $maxBytes) {
|
||||
fseek($fp, -$maxBytes, SEEK_END);
|
||||
}
|
||||
$buf = @stream_get_contents($fp);
|
||||
@fclose($fp);
|
||||
if ($buf === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$buf = str_replace(["\r\n", "\r"], "\n", (string)$buf);
|
||||
$lines = array_filter(array_map('trim', explode("\n", $buf)), 'strlen');
|
||||
$tail = implode("\n", array_slice($lines, -30));
|
||||
|
||||
return [
|
||||
'path' => $path,
|
||||
'modifiedAt' => (int)@filemtime($path),
|
||||
'tail' => $tail,
|
||||
'hasError' => stripos($tail, 'error') !== false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the on-disk snapshot JSON, if present.
|
||||
*/
|
||||
public static function deleteSnapshot(): bool
|
||||
{
|
||||
$path = self::snapshotPath();
|
||||
if (!is_file($path)) {
|
||||
return false;
|
||||
}
|
||||
return @unlink($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute path to the snapshot JSON file.
|
||||
*/
|
||||
@@ -720,4 +784,4 @@ class DiskUsageModel
|
||||
if ($key === '' || $key === 'root') return 0;
|
||||
return substr_count(trim($key, '/'), '/') + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user