From bd4ac8426ad9224177ee016aa06b498f809476e0 Mon Sep 17 00:00:00 2001 From: NovaCPX Admin Date: Wed, 15 Jul 2026 13:50:05 -0500 Subject: [PATCH] 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). --- .gitignore | 1 + install.sh | 2 +- panel/admin/index.php | 4 + panel/api/endpoints/files.php | 155 +++++++++++++++---- panel/assets/js/admin.js | 276 ++++++++++++++++++++++++++++++++++ 5 files changed, 409 insertions(+), 29 deletions(-) diff --git a/.gitignore b/.gitignore index 7968cb9..7fa381f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ node_modules/ Thumbs.db panel/public/adminer.php panel/public/adminer.php +*.bak-* diff --git a/install.sh b/install.sh index 9dcd27c..50be8d7 100644 --- a/install.sh +++ b/install.sh @@ -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" diff --git a/panel/admin/index.php b/panel/admin/index.php index 721e9fe..95ca868 100644 --- a/panel/admin/index.php +++ b/panel/admin/index.php @@ -146,6 +146,10 @@ require_once dirname(__DIR__) . '/_branding.php'; Backups + + + File Manager + Cloudflare diff --git a/panel/api/endpoints/files.php b/panel/api/endpoints/files.php index 6e5fc81..75851d1 100644 --- a/panel/api/endpoints/files.php +++ b/panel/api/endpoints/files.php @@ -1,7 +1,14 @@ 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', - 'size' => $f->isFile() ? fmt_size($f->getSize()) : null, - 'size_raw' => $f->isFile() ? $f->getSize() : 0, - 'perms' => substr(sprintf('%o', $f->getPerms()), -4), - 'modified' => date('Y-m-d H:i', $f->getMTime()), - 'path' => str_replace($baseDir, '', $path . '/' . $f->getFilename()), + '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' => $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'); + 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'); })(), diff --git a/panel/assets/js/admin.js b/panel/assets/js/admin.js index 3cd46b4..817b8ef 100644 --- a/panel/assets/js/admin.js +++ b/panel/assets/js/admin.js @@ -98,6 +98,7 @@ twofa, updates, backups, + 'file-manager': fileManager, cloudflare, 'server-options': serverOptions, notifications, @@ -4619,3 +4620,278 @@ ${results.map(z=>` `).join('')} `; }; + +/* ── 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 = [`/`]; + for (const p of parts) { + acc += '/' + p; + const esc = acc.replace(/'/g, "\\'"); + crumbs.push(`${Nova.escHtml(p)}`); + } + return crumbs.join(' / '); +} + +async function fmTableHtml(path) { + const res = await Nova.api('files', 'list', { params: { path } }); + if (!res?.success) return `
${Nova.escHtml(res?.message || 'Error loading directory')}
`; + const items = res.data.items || []; + const parent = path === '/' ? null : (path.replace(/\/[^/]+$/, '') || '/'); + return `
+ + + ${parent !== null ? `` : ''} + ${items.map(f => { + const p = f.path.replace(/'/g, "\\'"); + const nameEsc = Nova.escHtml(f.name); + return ` + + + + + + + + `; + }).join('')} + ${!items.length ? `` : ''} + +
NameOwnerPermsSizeModifiedActions
.. (parent directory)
${f.type === 'dir' + ? `📁 ${nameEsc}${f.link ? ' (symlink)' : ''}` + : `📄 ${nameEsc}${f.link ? ' (symlink)' : ''}`}${Nova.escHtml(f.owner)}:${Nova.escHtml(f.group)}${f.perms}${f.size || '—'}${f.modified} + ${f.type === 'file' ? `` : ''} + ${f.type === 'file' ? `` : ''} + ${f.type === 'file' && /\.(zip|tar|gz|tgz)$/i.test(f.name) ? `` : ''} + + + + +
Empty directory
`; +} + +async function fileManager() { + _fmPath = '/'; + const tableHtml = await fmTableHtml(_fmPath); + return ` + +
+
${fmBreadcrumb(_fmPath)}
+
${tableHtml}
+
+ +

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.

`; +} + +window.fmNav = async (path) => { + _fmPath = path; + const wrap = document.getElementById('fm-table-wrap'); + const crumb = document.getElementById('fm-breadcrumb'); + if (!wrap) return; + wrap.innerHTML = '
Loading…
'; + 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 = `
+
Editing: ${Nova.escHtml(name)} +
+ + +
+
+ +
`; + 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', `
`, + ` + `); +}; +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', `
`, + ` + `); +}; +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', `
`, + ` + `); +}; +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', ` +
+
+

Only succeeds if the web server's OS user has permission to change ownership to the target user/group.

`, + ` + `); +}; +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', `
`, + ` + `); +}; +window.fmSubmitUpload = () => { + const fileInput = document.getElementById('fm-upfile'); + if (!fileInput?.files[0]) return; + document.querySelector('.modal-overlay')?.remove(); + fmGuard(_fmPath, 'upload a file into', async (confirm_dangerous) => { + const fd = new FormData(); + fd.append('file', fileInput.files[0]); + fd.append('path', _fmPath); + fd.append('confirm_dangerous', confirm_dangerous ? '1' : ''); + const res = await fetch(`/api/files/upload?path=${encodeURIComponent(_fmPath)}`, { method: 'POST', credentials: 'include', body: fd }).then(r => r.json()); + if (res?.success) { Nova.toast('Uploaded', 'success'); fmNav(_fmPath); } else Nova.toast(res?.message || 'Upload failed', 'error'); + }); +}; +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', `
+

${sel.length} item(s) selected

`, + ` + `); +}; +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', ` +

You are about to ${Nova.escHtml(actionLabel)} a path in a system/security-critical location:

+

${Nova.escHtml(hit)}

+

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.

+
+ +
+
+ + +
`, + ` + ` + ); +} +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(); +};