Compare commits

12 Commits

Author SHA1 Message Date
github-actions[bot] be3dbec68a chore: bump version to 1.0.78 [skip ci] 2026-07-21 00:47:36 +00:00
Myron Blair a299447c9c Revert "Remove Claude/Anthropic references from repo"
This reverts commit 35aa202a4f.
2026-07-20 19:47:21 -05:00
github-actions[bot] 25233b9496 chore: bump version to 1.0.77 [skip ci] 2026-07-21 00:32:56 +00:00
Myron Blair 35aa202a4f Remove Claude/Anthropic references from repo 2026-07-20 19:32:43 -05:00
github-actions[bot] 2beffd810d chore: bump version to 1.0.76 [skip ci] 2026-07-15 20:00:19 +00:00
NovaCPX Admin 3d2c7c25c2 fix: File Manager large uploads had no server-processing feedback and short nginx timeouts
Reaching 100% in the upload progress bar only means the browser finished SENDING the bytes to nginx - it does not mean the server has finished receiving/saving the file. For multi-GB files this gap was invisible, making a genuinely-still-working upload look frozen at 100 percent, leading to a premature reload (confirmed via nginx access log: status 499, client closed request).

nginx-panel.conf: added fastcgi_request_buffering off on the admin panels /api/ location so nginx streams the body straight to PHP-FPM instead of fully buffering it to a temp file first and reading it back (a full extra disk I/O pass for large files); also added client_body_timeout/fastcgi_send_timeout/send_timeout at 1800s alongside the existing fastcgi_read_timeout, since those are separate timeout phases that previously still defaulted to 60s.

admin.js: added an xhr.upload load-complete handler that switches the dialog to a clear "Finishing up on the server..." state once the send phase hits 100%, so large uploads no longer look stuck during the save phase.
2026-07-15 14:49:53 -05:00
github-actions[bot] 8adedb9759 chore: bump version to 1.0.75 [skip ci] 2026-07-15 19:34:25 +00:00
NovaCPX Admin 827a4e8297 fix: File Manager upload had no size limit for large files and no progress feedback
nginx-panel.conf: added client_max_body_size 20g at the http level (this nginx instance only serves the NovaCPX panel, isolated from the system nginx), and scoped generous PHP upload limits (upload_max_filesize/post_max_size 20G, execution/input time 1800s) to just the admin panels /api/ location via fastcgi_param PHP_VALUE - this avoids touching the shared php8.3-fpm www pool config used by other sites.

admin.js: rewrote fmSubmitUpload from fetch() (no progress support, and would throw uncaught on a non-JSON error body like an nginx 413 page) to XMLHttpRequest with a real progress bar (percent + bytes transferred) that stays visible for the whole transfer instead of the dialog closing immediately on click.
2026-07-15 14:33:36 -05:00
github-actions[bot] 14cb285209 chore: bump version to 1.0.74 [skip ci] 2026-07-15 19:20:34 +00:00
NovaCPX Admin d6321c3268 fix: File Manager upload never read target path from multipart POST/query string
upload only checked [path], which is parsed from JSON php://input and is always empty for multipart/form-data uploads. It silently fell back to a hardcoded /public_html, which does not exist at filesystem root - breaking uploads for the admin File Manager (baseDir=/) entirely, though it went unnoticed for regular hosting accounts since /public_html happens to be their real default. Now falls back through  and  as well.
2026-07-15 14:19:44 -05:00
github-actions[bot] bc691466cd chore: bump version to 1.0.73 [skip ci] 2026-07-15 19:02:50 +00:00
NovaCPX Admin bd4ac8426a feat: admin File Manager with root filesystem access + dangerous-path safety guard
- New File Manager page under Admin > System, built on the existing /api/files endpoint (admin sessions with no linked hosting account get baseDir=/, i.e. full filesystem).

- Fixed two baseDir=/ edge-case bugs in files.php: safe_path() double-slash prefix check, and list actions path stripping via str_replace corrupting every slash.

- Added download and chown actions to files.php.

- Added a server-enforced dangerous-path guard: write/delete/rename/chmod/chown/mkdir/upload/compress/extract targeting /etc,/boot,/root,/sys,/proc,/dev,/run,/lib*,/bin,/sbin,/var/lib,/snap are rejected with 409 unless confirm_dangerous=true is sent - matched by a client-side warning modal requiring a checkbox and typed CONFIRM.

- install.sh: added zip package (required by the existing compress action, was missing).
2026-07-15 13:50:05 -05:00
7 changed files with 501 additions and 32 deletions
+1
View File
@@ -10,3 +10,4 @@ node_modules/
Thumbs.db
panel/public/adminer.php
panel/public/adminer.php
*.bak-*
+1 -1
View File
@@ -1 +1 @@
1.0.72
1.0.78
+15 -1
View File
@@ -14,6 +14,9 @@ http {
sendfile on;
gzip on;
# Large uploads via the admin File Manager (e.g. ISOs, installers) can be multi-GB.
client_max_body_size 20g;
# ── Admin Panel (8882) ─────────────────────────────────────────────────────
server {
listen 8882 ssl;
@@ -43,7 +46,18 @@ http {
include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME /srv/novacpx/public/api/index.php;
fastcgi_param SERVER_PORT 8882;
fastcgi_read_timeout 300;
fastcgi_read_timeout 1800;
fastcgi_send_timeout 1800;
client_body_timeout 1800;
send_timeout 1800;
# Stream the request body straight to PHP-FPM instead of nginx buffering
# the whole thing to a temp file first and then reading it back — cuts a
# full extra disk read+write pass for large uploads (ISOs, installers).
fastcgi_request_buffering off;
fastcgi_param PHP_VALUE "upload_max_filesize=20G
post_max_size=20G
max_execution_time=1800
max_input_time=1800";
}
}
+1 -1
View File
@@ -133,7 +133,7 @@ export DEBIAN_FRONTEND=noninteractive
apt-get update -qq >> "$LOG" 2>&1
apt-get upgrade -y -qq >> "$LOG" 2>&1
apt-get install -y -qq curl wget gnupg2 lsb-release ca-certificates \
software-properties-common apt-transport-https unzip git \
software-properties-common apt-transport-https zip unzip git \
sudo cron logrotate ufw fail2ban sshpass sqlite3 >> "$LOG" 2>&1
log "System packages updated"
+4
View File
@@ -146,6 +146,10 @@ require_once dirname(__DIR__) . '/_branding.php';
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Backups
</a>
<a href="#" class="sidebar-link" data-page="file-manager">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
File Manager
</a>
<a href="#" class="sidebar-link" data-page="cloudflare">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9z"/></svg>
Cloudflare
+122 -23
View File
@@ -1,7 +1,14 @@
<?php
/**
* Files endpoint — file manager (list, read, write, rename, delete, chmod, upload, archive)
* Strictly confined to account home directory via realpath check
* Files endpoint — file manager (list, read, write, rename, delete, chmod, chown, upload, download, archive)
* Strictly confined to account home directory via realpath check.
* Admins with no linked hosting account get baseDir = '/' (full filesystem, bounded by the
* OS permissions of the PHP-FPM pool user — see safe_path()).
*
* Destructive actions (write/delete/rename/chmod/chown/extract/mkdir/upload) targeting a
* path under a system/security-critical zone (see is_dangerous_path()) are rejected unless
* the request includes confirm_dangerous=true. This is enforced here, not just in the UI,
* so it can't be bypassed by calling the API directly.
*/
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
@@ -11,11 +18,15 @@ $acct = $db->fetchOne("SELECT * FROM accounts WHERE user_id = ?", [$user['uid']]
if (!$acct && $user['role'] !== 'admin') Response::error("No hosting account found", 404);
$baseDir = $acct ? realpath($acct['home_dir']) : '/';
// Normalize so the prefix check below doesn't break when baseDir is '/' (rtrim would otherwise
// leave a doubled leading slash — '/' . '/' — that no real path can ever start with).
$baseDirPrefix = rtrim($baseDir, '/');
// For existing paths only — throws if path doesn't exist or escapes baseDir
function safe_path(string $base, string $rel): string {
global $baseDirPrefix;
$full = realpath($base . '/' . ltrim($rel, '/'));
if ($full === false || !str_starts_with($full . '/', $base . '/')) {
if ($full === false || !str_starts_with($full . '/', $baseDirPrefix . '/')) {
throw new RuntimeException("Path outside account directory");
}
return $full;
@@ -23,14 +34,39 @@ function safe_path(string $base, string $rel): string {
// For paths that may not exist yet (write/mkdir) — validates parent dir
function safe_path_new(string $base, string $rel): string {
global $baseDirPrefix;
$candidate = $base . '/' . ltrim(str_replace(['../', '..\\'], '', $rel), '/');
$parent = realpath(dirname($candidate));
if ($parent === false || !str_starts_with($parent . '/', $base . '/')) {
if ($parent === false || !str_starts_with($parent . '/', $baseDirPrefix . '/')) {
throw new RuntimeException("Path outside account directory");
}
return $parent . '/' . basename($candidate);
}
// Only meaningful for true root access (baseDir === '/'); hosting accounts are always
// confined under a home directory and never reach these zones.
function is_dangerous_path(string $baseDir, string $fullPath): bool {
if ($baseDir !== '/') return false;
static $zones = ['/etc','/boot','/root','/sys','/proc','/dev','/run',
'/lib','/lib64','/usr/lib','/usr/lib64',
'/bin','/sbin','/usr/bin','/usr/sbin','/var/lib','/snap'];
foreach ($zones as $z) {
if ($fullPath === $z || str_starts_with($fullPath, $z . '/')) return true;
}
return false;
}
function require_dangerous_confirm(string $baseDir, string $path, array $body): void {
if (is_dangerous_path($baseDir, $path) && !($body['confirm_dangerous'] ?? $_POST['confirm_dangerous'] ?? false)) {
Response::error(
"This path is in a system/security-critical location. Editing, deleting, or changing " .
"permissions here can break services or compromise server security. Confirmation required.",
409,
['dangerous_path' => true]
);
}
}
function fmt_size(int $bytes): string {
if ($bytes >= 1073741824) return round($bytes/1073741824, 1) . 'GB';
if ($bytes >= 1048576) return round($bytes/1048576, 1) . 'MB';
@@ -39,35 +75,59 @@ function fmt_size(int $bytes): string {
}
match ($action) {
'list' => (function() use ($baseDir, $body) {
$rel = $body['path'] ?? $_GET['path'] ?? '/public_html';
'list' => (function() use ($baseDir, $body, $user) {
$rel = $body['path'] ?? $_GET['path'] ?? ($user['role'] === 'admin' ? '/' : '/public_html');
$path = safe_path($baseDir, $rel);
if (!is_dir($path)) Response::error("Not a directory");
if (!is_readable($path)) Response::error("Permission denied reading this directory");
$hasPosix = function_exists('posix_getpwuid');
$items = [];
foreach (new DirectoryIterator($path) as $f) {
if ($f->isDot()) continue;
$uid = fileowner($f->getPathname());
$gid = filegroup($f->getPathname());
$itemPath = (($p = substr($path . '/' . $f->getFilename(), strlen(rtrim($baseDir, '/')))) === '' ? '/' : $p);
$items[] = [
'name' => $f->getFilename(),
'type' => $f->isDir() ? 'dir' : 'file',
'link' => $f->isLink(),
'size' => $f->isFile() ? fmt_size($f->getSize()) : null,
'size_raw' => $f->isFile() ? $f->getSize() : 0,
'perms' => substr(sprintf('%o', $f->getPerms()), -4),
'owner' => $hasPosix ? (@posix_getpwuid($uid)['name'] ?? (string)$uid) : (string)$uid,
'group' => $hasPosix ? (@posix_getgrgid($gid)['name'] ?? (string)$gid) : (string)$gid,
'modified' => date('Y-m-d H:i', $f->getMTime()),
'path' => str_replace($baseDir, '', $path . '/' . $f->getFilename()),
'path' => $itemPath,
'writable' => $f->isWritable(),
'dangerous' => is_dangerous_path($baseDir, $path . '/' . $f->getFilename()),
];
}
usort($items, fn($a,$b) => ($a['type'] === 'dir' ? -1 : 1) - ($b['type'] === 'dir' ? -1 : 1) ?: strcmp($a['name'], $b['name']));
Response::success(['path' => $rel, 'items' => $items]);
Response::success(['path' => $rel, 'items' => $items, 'base_writable' => is_writable($path)]);
})(),
'read' => (function() use ($baseDir, $body) {
$path = safe_path($baseDir, $body['path'] ?? $_GET['path'] ?? '');
if (!is_file($path)) Response::error("Not a file");
if (filesize($path) > 2 * 1024 * 1024) Response::error("File too large to edit (>2MB)");
if (!is_readable($path)) Response::error("Permission denied reading this file");
if (filesize($path) > 2 * 1024 * 1024) Response::error("File too large to edit (>2MB) — use Download instead");
Response::success(['content' => file_get_contents($path), 'path' => $body['path'] ?? '']);
})(),
'download' => (function() use ($baseDir, $body) {
$path = safe_path($baseDir, $body['path'] ?? $_GET['path'] ?? '');
if (!is_file($path)) Response::error("Not a file");
if (!is_readable($path)) Response::error("Permission denied reading this file");
audit('files.download', $body['path'] ?? $_GET['path'] ?? '');
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . basename($path) . '"');
header('Content-Length: ' . filesize($path));
header('X-Content-Type-Options: nosniff');
readfile($path);
exit;
})(),
'write' => (function() use ($baseDir, $body) {
$rel = $body['path'] ?? '';
$content = $body['content'] ?? '';
@@ -77,6 +137,8 @@ match ($action) {
} catch (RuntimeException) {
$path = safe_path_new($baseDir, $rel);
}
require_dangerous_confirm($baseDir, $path, $body);
if (file_exists($path) && !is_writable($path)) Response::error("Permission denied writing this file");
// PHP syntax check for .php files
if (str_ends_with($path, '.php')) {
$tmp = tempnam(sys_get_temp_dir(), 'ncpx_');
@@ -85,34 +147,41 @@ match ($action) {
unlink($tmp);
if (!str_contains($result ?? '', 'No syntax errors')) Response::error("PHP syntax error: $result");
}
file_put_contents($path, $content);
if (file_put_contents($path, $content) === false) Response::error("Write failed (permission denied)");
audit('files.write', $rel);
Response::success(null, 'File saved');
})(),
'mkdir' => (function() use ($baseDir, $body) {
$path = safe_path_new($baseDir, $body['path'] ?? '');
require_dangerous_confirm($baseDir, $path, $body);
if (is_dir($path)) Response::error("Directory already exists");
if (!mkdir($path, 0755, true)) Response::error("Could not create directory");
if (!mkdir($path, 0755, true)) Response::error("Could not create directory (permission denied)");
audit('files.mkdir', $body['path'] ?? '');
Response::success(null, 'Directory created');
})(),
'rename' => (function() use ($baseDir, $body) {
$from = safe_path($baseDir, $body['from'] ?? '');
$to = safe_path_new($baseDir, $body['to'] ?? '');
require_dangerous_confirm($baseDir, $from, $body);
require_dangerous_confirm($baseDir, $to, $body);
if (file_exists($to)) Response::error("Destination already exists");
rename($from, $to);
if (!rename($from, $to)) Response::error("Rename failed (permission denied)");
audit('files.rename', ($body['from'] ?? '') . ' -> ' . ($body['to'] ?? ''));
Response::success(null, 'Renamed');
})(),
'delete' => (function() use ($baseDir, $body) {
$path = safe_path($baseDir, $body['path'] ?? '');
// Prevent deleting the account root or its direct children (public_html, logs, tmp)
if ($path === $baseDir || dirname($path) === $baseDir) Response::error("Cannot delete root account directories");
// Prevent deleting the root directory itself or its direct children
// (for accounts: public_html/logs/tmp; for admin baseDir='/': /etc, /home, /var, /root, etc.)
if ($path === $baseDir || dirname($path) === $baseDir) Response::error("Cannot delete a top-level directory");
require_dangerous_confirm($baseDir, $path, $body);
if (is_dir($path)) {
shell_exec("rm -rf " . escapeshellarg($path));
shell_exec("rm -rf " . escapeshellarg($path) . " 2>&1");
} else {
unlink($path);
if (!unlink($path)) Response::error("Delete failed (permission denied)");
}
audit('files.delete', $body['path'] ?? '');
Response::success(null, 'Deleted');
@@ -120,42 +189,72 @@ match ($action) {
'chmod' => (function() use ($baseDir, $body) {
$path = safe_path($baseDir, $body['path'] ?? '');
require_dangerous_confirm($baseDir, $path, $body);
$perms = octdec((string)($body['perms'] ?? '755'));
// Clamp to sane range: no setuid/setgid/sticky, no world-write on dirs
$perms = $perms & 0777;
chmod($path, $perms);
if (!chmod($path, $perms)) Response::error("chmod failed (permission denied)");
audit('files.chmod', $body['path'] ?? '', ['perms' => decoct($perms)]);
Response::success(null, 'Permissions updated');
})(),
'chown' => (function() use ($baseDir, $body, $user) {
if ($user['role'] !== 'admin') Response::error("Forbidden", 403);
if (!function_exists('posix_getpwnam')) Response::error("posix extension not available on this server");
$path = safe_path($baseDir, $body['path'] ?? '');
require_dangerous_confirm($baseDir, $path, $body);
$owner = trim((string)($body['owner'] ?? ''));
$group = trim((string)($body['group'] ?? ''));
if ($owner !== '') {
$pw = @posix_getpwnam($owner);
if (!$pw) Response::error("Unknown user: $owner");
if (!@chown($path, $pw['uid'])) Response::error("chown failed — PHP-FPM ({$user['username']}) does not have OS permission to change ownership to '$owner'");
}
if ($group !== '') {
$gr = @posix_getgrnam($group);
if (!$gr) Response::error("Unknown group: $group");
if (!@chgrp($path, $gr['gid'])) Response::error("chgrp failed — PHP-FPM does not have OS permission to change group to '$group'");
}
audit('files.chown', $body['path'] ?? '', ['owner' => $owner, 'group' => $group]);
Response::success(null, 'Ownership updated');
})(),
'upload' => (function() use ($baseDir, $body) {
$dir = safe_path($baseDir, $body['path'] ?? '/public_html');
$dir = safe_path($baseDir, $body['path'] ?? $_POST['path'] ?? $_GET['path'] ?? '/public_html');
require_dangerous_confirm($baseDir, $dir, $body);
if (!is_dir($dir)) Response::error("Target directory not found");
if (!is_writable($dir)) Response::error("Permission denied writing to this directory");
if (empty($_FILES['file'])) Response::error("No file uploaded");
$filename = basename($_FILES['file']['name']);
if ($filename === '' || $filename === '.') Response::error("Invalid filename");
$dest = $dir . '/' . $filename;
if (!str_starts_with($dest, $baseDir . '/')) Response::error("Invalid destination");
move_uploaded_file($_FILES['file']['tmp_name'], $dest);
if (!str_starts_with($dest, rtrim($baseDir, '/') . '/')) Response::error("Invalid destination");
if (!move_uploaded_file($_FILES['file']['tmp_name'], $dest)) Response::error("Upload failed (permission denied)");
audit('files.upload', ($body['path'] ?? '') . '/' . $filename);
Response::success(['name' => $filename], 'File uploaded');
})(),
'compress' => (function() use ($baseDir, $body) {
$paths = array_map(fn($p) => safe_path($baseDir, $p), (array)($body['paths'] ?? []));
$dest = safe_path_new($baseDir, $body['dest'] ?? 'archive.zip');
require_dangerous_confirm($baseDir, $dest, $body);
$files = implode(' ', array_map('escapeshellarg', $paths));
shell_exec("cd " . escapeshellarg($baseDir) . " && zip -r " . escapeshellarg($dest) . " $files 2>/dev/null");
shell_exec("cd " . escapeshellarg($baseDir) . " && zip -r " . escapeshellarg($dest) . " $files 2>&1");
audit('files.compress', $body['dest'] ?? '', ['paths' => $body['paths'] ?? []]);
Response::success(null, 'Archive created');
})(),
'extract' => (function() use ($baseDir, $body) {
$file = safe_path($baseDir, $body['path'] ?? '');
$dest = safe_path($baseDir, $body['dest'] ?? dirname($body['path'] ?? '/'));
require_dangerous_confirm($baseDir, $dest, $body);
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
match($ext) {
'zip' => shell_exec("unzip -o " . escapeshellarg($file) . " -d " . escapeshellarg($dest)),
'gz','tgz','tar' => shell_exec("tar xf " . escapeshellarg($file) . " -C " . escapeshellarg($dest)),
'zip' => shell_exec("unzip -o " . escapeshellarg($file) . " -d " . escapeshellarg($dest) . " 2>&1"),
'gz','tgz','tar' => shell_exec("tar xf " . escapeshellarg($file) . " -C " . escapeshellarg($dest) . " 2>&1"),
default => Response::error("Unsupported archive type"),
};
audit('files.extract', $body['path'] ?? '');
Response::success(null, 'Extracted');
})(),
+351
View File
@@ -98,6 +98,7 @@
twofa,
updates,
backups,
'file-manager': fileManager,
cloudflare,
'server-options': serverOptions,
notifications,
@@ -4619,3 +4620,353 @@ ${results.map(z=>`<tr>
</tr>`).join('')}
</tbody></table></div>`;
};
/* File Manager (admin: root filesystem access)
* Backed by /api/files/*. Admin sessions have no linked hosting account, so
* the endpoint's baseDir resolves to '/' full filesystem path traversal,
* bounded only by the OS permissions of the PHP-FPM pool user (www-data).
*/
let _fmPath = '/';
function fmBreadcrumb(path) {
const parts = path.split('/').filter(Boolean);
let acc = '';
const crumbs = [`<a href="#" onclick="fmNav('/');return false" style="color:var(--primary)">/</a>`];
for (const p of parts) {
acc += '/' + p;
const esc = acc.replace(/'/g, "\\'");
crumbs.push(`<a href="#" onclick="fmNav('${esc}');return false" style="color:var(--primary)">${Nova.escHtml(p)}</a>`);
}
return crumbs.join(' / ');
}
async function fmTableHtml(path) {
const res = await Nova.api('files', 'list', { params: { path } });
if (!res?.success) return `<div class="empty" style="padding:2rem">${Nova.escHtml(res?.message || 'Error loading directory')}</div>`;
const items = res.data.items || [];
const parent = path === '/' ? null : (path.replace(/\/[^/]+$/, '') || '/');
return `<div class="table-wrap"><table>
<thead><tr><th style="width:2rem"></th><th>Name</th><th>Owner</th><th>Perms</th><th>Size</th><th>Modified</th><th>Actions</th></tr></thead>
<tbody>
${parent !== null ? `<tr><td></td><td colspan="6"><a href="#" onclick="fmNav('${parent.replace(/'/g, "\\'")}');return false" style="color:var(--primary)">.. (parent directory)</a></td></tr>` : ''}
${items.map(f => {
const p = f.path.replace(/'/g, "\\'");
const nameEsc = Nova.escHtml(f.name);
return `<tr>
<td><input type="checkbox" class="fm-sel" value="${Nova.escHtml(f.path)}"></td>
<td>${f.type === 'dir'
? `<a href="#" onclick="fmNav('${p}');return false" style="color:var(--sky)">📁 ${nameEsc}</a>${f.link ? ' <span class="text-muted text-sm">(symlink)</span>' : ''}`
: `📄 ${nameEsc}${f.link ? ' <span class="text-muted text-sm">(symlink)</span>' : ''}`}</td>
<td class="text-muted text-sm">${Nova.escHtml(f.owner)}:${Nova.escHtml(f.group)}</td>
<td><code style="font-size:.8rem">${f.perms}</code></td>
<td class="text-muted text-sm">${f.size || '—'}</td>
<td class="text-muted text-sm">${f.modified}</td>
<td style="display:flex;gap:.2rem;flex-wrap:wrap">
${f.type === 'file' ? `<button class="btn btn-xs" onclick="fmEdit('${p}','${nameEsc}')">Edit</button>` : ''}
${f.type === 'file' ? `<a class="btn btn-xs" href="/api/files/download?path=${encodeURIComponent(f.path)}">↓</a>` : ''}
${f.type === 'file' && /\.(zip|tar|gz|tgz)$/i.test(f.name) ? `<button class="btn btn-xs" onclick="fmExtract('${p}')">Extract</button>` : ''}
<button class="btn btn-xs" onclick="fmRename('${p}','${nameEsc}')">Ren</button>
<button class="btn btn-xs" onclick="fmChmod('${p}','${f.perms}')">Perm</button>
<button class="btn btn-xs" onclick="fmChown('${p}','${Nova.escHtml(f.owner)}','${Nova.escHtml(f.group)}')">Own</button>
<button class="btn btn-xs btn-danger" onclick="fmDelete('${p}','${nameEsc}')">Del</button>
</td>
</tr>`;
}).join('')}
${!items.length ? `<tr><td colspan="7" class="text-muted" style="text-align:center;padding:1.5rem">Empty directory</td></tr>` : ''}
</tbody>
</table></div>`;
}
async function fileManager() {
_fmPath = '/';
const tableHtml = await fmTableHtml(_fmPath);
return `
<div class="page-header mb-3">
<h2 class="page-title">File Manager</h2>
<div style="display:flex;gap:.5rem">
<button class="btn btn-sm" onclick="fmMkdir()">+ Folder</button>
<button class="btn btn-sm" onclick="fmUpload()"> Upload</button>
<button class="btn btn-sm" onclick="fmCompressSelected()">🗜 Compress Selected</button>
<button class="btn btn-ghost btn-sm" onclick="fmRefresh()"> Refresh</button>
</div>
</div>
<div class="card mb-2">
<div style="padding:.6rem 1rem;border-bottom:1px solid var(--border);font-family:monospace;font-size:.82rem" id="fm-breadcrumb">${fmBreadcrumb(_fmPath)}</div>
<div id="fm-table-wrap">${tableHtml}</div>
</div>
<div id="fm-editor" style="display:none;margin-top:1rem"></div>
<p class="text-muted text-sm">File operations run as the web server's own OS user (www-data) some root-owned system files may be visible here but not readable or writable.</p>`;
}
window.fmNav = async (path) => {
_fmPath = path;
const wrap = document.getElementById('fm-table-wrap');
const crumb = document.getElementById('fm-breadcrumb');
if (!wrap) return;
wrap.innerHTML = '<div style="padding:2rem;text-align:center;color:var(--text-muted)">Loading…</div>';
if (crumb) crumb.innerHTML = fmBreadcrumb(path);
wrap.innerHTML = await fmTableHtml(path);
};
window.fmRefresh = () => fmNav(_fmPath);
window.fmEdit = async (path, name) => {
const res = await Nova.api('files', 'read', { params: { path } });
if (!res?.success) { Nova.toast(res?.message || 'Cannot read file', 'error'); return; }
const ed = document.getElementById('fm-editor');
ed.style.display = 'block';
ed.innerHTML = `<div class="card">
<div class="card-header"><span class="card-title">Editing: ${Nova.escHtml(name)}</span>
<div style="display:flex;gap:.5rem">
<button class="btn btn-sm btn-primary" onclick="fmSave('${path.replace(/'/g, "\\'")}')">Save</button>
<button class="btn btn-sm" onclick="document.getElementById('fm-editor').style.display='none'">Close</button>
</div>
</div>
<textarea id="fm-code" style="width:100%;min-height:420px;font-family:monospace;font-size:.85rem;padding:1rem;background:var(--bg);color:var(--text);border:none;resize:vertical">${res.data.content.replace(/</g, '&lt;')}</textarea>
</div>`;
ed.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
};
window.fmSave = (path) => {
const content = document.getElementById('fm-code')?.value || '';
fmGuard(path, 'save', async (confirm_dangerous) => {
const res = await Nova.api('files', 'write', { method: 'POST', body: { path, content, confirm_dangerous } });
if (res?.success) Nova.toast('Saved', 'success'); else Nova.toast(res?.message || 'Save failed', 'error');
});
};
window.fmDelete = (path, name) => {
Nova.confirm(`Delete ${name}? This cannot be undone.`, () => {
fmGuard(path, 'delete', async (confirm_dangerous) => {
const res = await Nova.api('files', 'delete', { method: 'POST', body: { path, confirm_dangerous } });
if (res?.success) { Nova.toast('Deleted', 'success'); fmNav(_fmPath); }
else Nova.toast(res?.message, 'error');
});
}, true);
};
window.fmMkdir = () => {
Nova.modal('New Folder', `<div class="form-group"><label class="form-label">Folder Name</label><input id="fm-dname" class="form-control"></div>`,
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button class="btn btn-primary" onclick="fmSubmitMkdir()">Create</button>`);
};
window.fmSubmitMkdir = () => {
const name = document.getElementById('fm-dname')?.value || '';
if (!name) return;
const path = (_fmPath === '/' ? '' : _fmPath) + '/' + name;
document.querySelector('.modal-overlay')?.remove();
fmGuard(path, 'create a folder in', async (confirm_dangerous) => {
const res = await Nova.api('files', 'mkdir', { method: 'POST', body: { path, confirm_dangerous } });
if (res?.success) { Nova.toast('Created', 'success'); fmNav(_fmPath); } else Nova.toast(res?.message, 'error');
});
};
window.fmRename = (path, name) => {
const dir = path.replace(/\/[^/]+$/, '') || '/';
Nova.modal('Rename', `<div class="form-group"><label class="form-label">New Name</label><input id="fm-newname" class="form-control" value="${Nova.escHtml(name)}"></div>`,
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button class="btn btn-primary" onclick="fmSubmitRename('${path.replace(/'/g, "\\'")}','${dir.replace(/'/g, "\\'")}')">Rename</button>`);
};
window.fmSubmitRename = (path, dir) => {
const newname = document.getElementById('fm-newname')?.value || '';
const to = (dir === '/' ? '' : dir) + '/' + newname;
document.querySelector('.modal-overlay')?.remove();
fmGuard([path, to], 'rename', async (confirm_dangerous) => {
const res = await Nova.api('files', 'rename', { method: 'POST', body: { from: path, to, confirm_dangerous } });
if (res?.success) { Nova.toast('Renamed', 'success'); fmNav(_fmPath); } else Nova.toast(res?.message, 'error');
});
};
window.fmChmod = (path, current) => {
Nova.modal('Change Permissions', `<div class="form-group"><label class="form-label">Permissions (octal, e.g. 755)</label><input id="fm-perms" class="form-control" value="${current}" maxlength="4"></div>`,
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button class="btn btn-primary" onclick="fmSubmitChmod('${path.replace(/'/g, "\\'")}')">Update</button>`);
};
window.fmSubmitChmod = (path) => {
const perms = document.getElementById('fm-perms')?.value || '755';
document.querySelector('.modal-overlay')?.remove();
fmGuard(path, 'change permissions on', async (confirm_dangerous) => {
const res = await Nova.api('files', 'chmod', { method: 'POST', body: { path, perms, confirm_dangerous } });
if (res?.success) { Nova.toast('Updated', 'success'); fmNav(_fmPath); } else Nova.toast(res?.message, 'error');
});
};
window.fmChown = (path, owner, group) => {
Nova.modal('Change Ownership', `
<div class="form-group"><label class="form-label">Owner (user)</label><input id="fm-owner" class="form-control" value="${Nova.escHtml(owner)}"></div>
<div class="form-group"><label class="form-label">Group</label><input id="fm-group" class="form-control" value="${Nova.escHtml(group)}"></div>
<p class="text-muted text-sm">Only succeeds if the web server's OS user has permission to change ownership to the target user/group.</p>`,
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button class="btn btn-primary" onclick="fmSubmitChown('${path.replace(/'/g, "\\'")}')">Update</button>`);
};
window.fmSubmitChown = (path) => {
const owner = document.getElementById('fm-owner')?.value || '';
const group = document.getElementById('fm-group')?.value || '';
document.querySelector('.modal-overlay')?.remove();
fmGuard(path, 'change ownership on', async (confirm_dangerous) => {
const res = await Nova.api('files', 'chown', { method: 'POST', body: { path, owner, group, confirm_dangerous } });
if (res?.success) { Nova.toast('Ownership updated', 'success'); fmNav(_fmPath); } else Nova.toast(res?.message || 'chown failed', 'error');
});
};
window.fmUpload = () => {
Nova.modal('Upload File', `
<div id="fm-upload-form">
<div class="form-group"><label class="form-label">Select File</label><input id="fm-upfile" type="file" class="form-control"></div>
</div>
<div id="fm-upload-progress" style="display:none">
<div style="display:flex;justify-content:space-between;font-size:.85rem;margin-bottom:.35rem">
<span id="fm-upload-filename"></span>
<span id="fm-upload-pct">0%</span>
</div>
<div style="background:var(--bg);border-radius:4px;height:10px;overflow:hidden">
<div id="fm-upload-bar" style="background:var(--primary,#6366f1);height:100%;width:0%;transition:width .2s"></div>
</div>
<div id="fm-upload-bytes" class="text-muted text-sm" style="margin-top:.35rem"></div>
</div>`,
`<button class="btn btn-ghost" id="fm-upload-cancel-btn" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button class="btn btn-primary" id="fm-upload-go-btn" onclick="fmSubmitUpload()">Upload</button>`);
};
function fmFormatBytes(n) {
if (n >= 1073741824) return (n / 1073741824).toFixed(2) + ' GB';
if (n >= 1048576) return (n / 1048576).toFixed(1) + ' MB';
if (n >= 1024) return (n / 1024).toFixed(1) + ' KB';
return n + ' B';
}
window.fmSubmitUpload = () => {
const fileInput = document.getElementById('fm-upfile');
const file = fileInput?.files[0];
if (!file) return;
fmGuard(_fmPath, 'upload a file into', (confirm_dangerous) => {
// Switch the modal from the file-picker to a progress view; keep it open for the whole transfer.
document.getElementById('fm-upload-form').style.display = 'none';
document.getElementById('fm-upload-progress').style.display = 'block';
document.getElementById('fm-upload-filename').textContent = file.name;
document.getElementById('fm-upload-bytes').textContent = '0 B / ' + fmFormatBytes(file.size);
document.getElementById('fm-upload-cancel-btn').style.display = 'none';
const goBtn = document.getElementById('fm-upload-go-btn');
goBtn.disabled = true;
goBtn.textContent = 'Uploading…';
const fd = new FormData();
fd.append('file', file);
fd.append('path', _fmPath);
fd.append('confirm_dangerous', confirm_dangerous ? '1' : '');
const xhr = new XMLHttpRequest();
xhr.open('POST', `/api/files/upload?path=${encodeURIComponent(_fmPath)}`, true);
xhr.withCredentials = true;
xhr.upload.addEventListener('progress', (e) => {
if (!e.lengthComputable) return;
const pct = Math.round((e.loaded / e.total) * 100);
const bar = document.getElementById('fm-upload-bar');
const pctEl = document.getElementById('fm-upload-pct');
const bytesEl = document.getElementById('fm-upload-bytes');
if (bar) bar.style.width = pct + '%';
if (pctEl) pctEl.textContent = pct + '%';
if (bytesEl) bytesEl.textContent = fmFormatBytes(e.loaded) + ' / ' + fmFormatBytes(e.total);
});
// Reaching 100% here only means the browser finished SENDING the bytes — the
// server (nginx + PHP) still has to receive/move the file, which for large
// files can take a genuine extra stretch with no further upload events. Make
// that explicit so it doesn't look frozen.
xhr.upload.addEventListener('load', () => {
const pctEl = document.getElementById('fm-upload-pct');
const bytesEl = document.getElementById('fm-upload-bytes');
const bar = document.getElementById('fm-upload-bar');
if (bar) bar.style.background = '#f59e0b';
if (pctEl) pctEl.textContent = 'Finishing up…';
if (bytesEl) bytesEl.textContent = 'Upload sent — waiting for the server to finish saving the file. Large files can take a few extra minutes here. Do not close or reload this page.';
});
xhr.onload = () => {
let res = null;
try { res = JSON.parse(xhr.responseText); } catch (e) { /* non-JSON error page, e.g. nginx 413 */ }
if (xhr.status >= 200 && xhr.status < 300 && res?.success) {
document.querySelector('.modal-overlay')?.remove();
Nova.toast('Uploaded', 'success');
fmNav(_fmPath);
} else {
document.querySelector('.modal-overlay')?.remove();
const msg = res?.message || (xhr.status === 413 ? 'File too large for the server\'s current upload limit' : `Upload failed (HTTP ${xhr.status})`);
Nova.toast(msg, 'error');
}
};
xhr.onerror = () => {
document.querySelector('.modal-overlay')?.remove();
Nova.toast('Upload failed — connection error', 'error');
};
xhr.send(fd);
});
};
window.fmExtract = (path) => {
Nova.confirm(`Extract this archive into the current directory?`, () => {
fmGuard(_fmPath, 'extract an archive into', async (confirm_dangerous) => {
const res = await Nova.api('files', 'extract', { method: 'POST', body: { path, dest: _fmPath, confirm_dangerous } });
if (res?.success) { Nova.toast('Extracted', 'success'); fmNav(_fmPath); } else Nova.toast(res?.message, 'error');
});
});
};
window.fmCompressSelected = () => {
const sel = Array.from(document.querySelectorAll('.fm-sel:checked')).map(c => c.value);
if (!sel.length) { Nova.toast('Select at least one file or folder first', 'error'); return; }
window._fmCompressPaths = sel;
Nova.modal('Compress Selected', `<div class="form-group"><label class="form-label">Archive Name</label><input id="fm-arcname" class="form-control" value="archive.zip"></div>
<p class="text-muted text-sm">${sel.length} item(s) selected</p>`,
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button class="btn btn-primary" onclick="fmSubmitCompress()">Compress</button>`);
};
window.fmSubmitCompress = () => {
const paths = window._fmCompressPaths || [];
const name = document.getElementById('fm-arcname')?.value || 'archive.zip';
const dest = (_fmPath === '/' ? '' : _fmPath) + '/' + name;
document.querySelector('.modal-overlay')?.remove();
fmGuard(dest, 'create an archive in', async (confirm_dangerous) => {
const res = await Nova.api('files', 'compress', { method: 'POST', body: { paths, dest, confirm_dangerous } });
if (res?.success) { Nova.toast('Archive created', 'success'); fmNav(_fmPath); } else Nova.toast(res?.message, 'error');
});
};
/* File Manager: system/security-critical path guard
* Mirrors the server-side zone list in files.php's is_dangerous_path(). This
* client-side copy is only for showing the warning early the server enforces
* it regardless (rejects with dangerous_path:true unless confirm_dangerous is set),
* so this can't be bypassed by skipping the modal.
*/
const FM_DANGEROUS_ZONES = ['/etc','/boot','/root','/sys','/proc','/dev','/run',
'/lib','/lib64','/usr/lib','/usr/lib64','/bin','/sbin','/usr/bin','/usr/sbin','/var/lib','/snap'];
function fmIsDangerous(path) {
return FM_DANGEROUS_ZONES.some(z => path === z || path.startsWith(z + '/'));
}
// Wraps an action: if any of the given paths fall in a dangerous zone, shows a warning
// modal requiring a checkbox + typed "CONFIRM" before calling doIt(true). Otherwise
// calls doIt(false) immediately. doIt receives the confirm_dangerous flag to include in the API body.
function fmGuard(paths, actionLabel, doIt) {
const list = (Array.isArray(paths) ? paths : [paths]).filter(Boolean);
const hit = list.find(fmIsDangerous);
if (!hit) { doIt(false); return; }
window._fmGuardCallback = () => doIt(true);
Nova.modal('⚠️ System File Warning', `
<p style="color:#ef4444;font-weight:600;margin-bottom:.5rem">You are about to ${Nova.escHtml(actionLabel)} a path in a system/security-critical location:</p>
<p style="font-family:monospace;background:var(--bg);padding:.5rem .75rem;border-radius:4px;font-size:.85rem">${Nova.escHtml(hit)}</p>
<p class="text-muted text-sm" style="margin-top:.75rem">Editing, deleting, or changing permissions/ownership outside the web server's own files (www-data) can break running services, lock out access to this server, or open a security hole. This is not easily undone.</p>
<div class="form-group" style="margin-top:1rem">
<label style="display:flex;gap:.5rem;align-items:center;font-weight:normal">
<input type="checkbox" id="fm-danger-ack" style="width:auto">
I understand the risk and want to proceed anyway
</label>
</div>
<div class="form-group">
<label class="form-label">Type <code>CONFIRM</code> to continue</label>
<input id="fm-danger-type" class="form-control" placeholder="CONFIRM" autocomplete="off">
</div>`,
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button class="btn btn-danger" onclick="fmGuardProceed()">Proceed Anyway</button>`
);
}
window.fmGuardProceed = () => {
const ack = document.getElementById('fm-danger-ack')?.checked;
const typed = (document.getElementById('fm-danger-type')?.value || '').trim();
if (!ack || typed !== 'CONFIRM') { Nova.toast('Check the box and type CONFIRM exactly to proceed', 'error'); return; }
document.querySelector('.modal-overlay')?.remove();
const cb = window._fmGuardCallback;
window._fmGuardCallback = null;
if (cb) cb();
};