Merge pull request #1742 from master3395/v2.5.5-dev

V2.5.5 dev
This commit is contained in:
Master3395
2026-03-25 10:58:05 +01:00
committed by GitHub
16 changed files with 1206 additions and 346 deletions

View File

@@ -53,6 +53,20 @@ _default_origins = [
# Merge environment and default origins, avoiding duplicates
CSRF_TRUSTED_ORIGINS = list(dict.fromkeys(_csrf_origins_list + _default_origins))
# Optional file: one trusted origin per line (e.g. https://203.0.113.1:2087) for IP:port panel access.
# Create /etc/cyberpanel/csrf_trusted_origins on the server if JSON POSTs get 403 CSRF when using HTTPS by IP.
_csrf_trusted_origins_file = '/etc/cyberpanel/csrf_trusted_origins'
if os.path.isfile(_csrf_trusted_origins_file):
try:
with open(_csrf_trusted_origins_file, 'r', encoding='utf-8', errors='replace') as _csrf_f:
for _csrf_line in _csrf_f:
_csrf_line = _csrf_line.strip()
if _csrf_line and not _csrf_line.startswith('#'):
if _csrf_line not in CSRF_TRUSTED_ORIGINS:
CSRF_TRUSTED_ORIGINS.append(_csrf_line)
except OSError:
pass
# Application definition
INSTALLED_APPS = [

View File

@@ -2130,7 +2130,11 @@
</a>
{% endif %}
</div>
{% endif %}
{% if admin or managePlugins %}
{% if managePlugins and not admin %}
<div class="section-header">{% trans "PLUGINS" %}</div>
{% endif %}
<a href="#" class="menu-item" onclick="toggleSubmenu('plugins-submenu', this); return false;">
<div class="icon-wrapper">
<i class="fas fa-plug"></i>

View File

@@ -283,6 +283,17 @@ class ACLManager:
finalResponse['mailServerSSL'] = config['mailServerSSL']
finalResponse['sslReconcile'] = config.get('sslReconcile', 0)
## Plugin management (Plugin Store / installed plugins UI and APIs)
_mpv = config.get('managePlugins', 0)
if _mpv in (1, True, '1', 'true'):
finalResponse['managePlugins'] = 1
else:
try:
finalResponse['managePlugins'] = 1 if int(_mpv) else 0
except (TypeError, ValueError):
finalResponse['managePlugins'] = 0
return finalResponse
@staticmethod

50
plogical/plugin_acl.py Normal file
View File

@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
"""Shared ACL checks for CyberPanel plugin management (core + store plugins)."""
from functools import wraps
from django.http import JsonResponse
def user_can_manage_plugins(request):
"""True if session user is full admin or has managePlugins ACL."""
try:
user_id = request.session['userID']
except KeyError:
return False
try:
from plogical.acl import ACLManager
acl = ACLManager.loadedACL(user_id)
if acl.get('admin') == 1:
return True
try:
return int(acl.get('managePlugins', 0) or 0) == 1
except (TypeError, ValueError):
return False
except BaseException:
return False
def deny_plugin_manage_json_response(request):
"""401 if no session, else 403 JSON for plugin management APIs."""
try:
request.session['userID']
except KeyError:
return JsonResponse({
'success': False,
'error_message': 'Authentication required.',
'error': 'Authentication required.',
}, status=401)
return JsonResponse({
'success': False,
'error_message': 'You are not authorized to manage plugins.',
'error': 'You are not authorized to manage plugins.',
}, status=403)
def require_manage_plugins_api(view_func):
"""Decorator: JSON 401/403 if user cannot manage plugins (use after login/session check)."""
@wraps(view_func)
def _wrapped(request, *args, **kwargs):
if not user_can_manage_plugins(request):
return deny_plugin_manage_json_response(request)
return view_func(request, *args, **kwargs)
return _wrapped

View File

@@ -12,11 +12,13 @@ import json
from datetime import datetime, timedelta
from xml.etree import ElementTree
from plogical.httpProc import httpProc
from plogical.plugin_acl import user_can_manage_plugins, deny_plugin_manage_json_response
from plogical.CyberCPLogFileWriter import CyberCPLogFileWriter as logging
import sys
import urllib.request
import urllib.error
import time
import inspect
sys.path.append('/usr/local/CyberCP')
from pluginInstaller.pluginInstaller import pluginInstaller
from .patreon_verifier import PatreonVerifier
@@ -43,6 +45,36 @@ PLUGIN_SOURCE_PATHS = ['/home/cyberpanel/plugins', '/home/cyberpanel-plugins']
# These plugins show "Built-in" badge and only Settings button (no Deactivate/Uninstall)
BUILTIN_PLUGINS = frozenset(['emailMarketing', 'emailPremium'])
def _install_plugin_compat(plugin_name, zip_path_abs):
"""
Call pluginInstaller.installPlugin with zip_path when supported (newer CyberPanel).
Older installs only accept installPlugin(pluginName) and expect pluginName.zip in CWD.
"""
zip_path_abs = os.path.abspath(zip_path_abs)
work_dir = os.path.dirname(zip_path_abs)
use_zip_kw = False
try:
sig = inspect.signature(pluginInstaller.installPlugin)
use_zip_kw = 'zip_path' in sig.parameters
except (TypeError, ValueError):
use_zip_kw = False
if use_zip_kw:
pluginInstaller.installPlugin(plugin_name, zip_path=zip_path_abs)
return
prev_cwd = os.getcwd()
try:
os.chdir(work_dir)
expected_zip = os.path.join(work_dir, plugin_name + '.zip')
if zip_path_abs != expected_zip:
shutil.copy2(zip_path_abs, expected_zip)
pluginInstaller.installPlugin(plugin_name)
finally:
try:
os.chdir(prev_cwd)
except Exception:
pass
# Core CyberPanel app dirs under /usr/local/CyberCP that must not be counted as "installed plugins"
# (matches pluginHolder.urls so Installed count = store/plugin dirs only, not core apps)
RESERVED_PLUGIN_DIRS = frozenset([
@@ -175,7 +207,7 @@ def _set_plugin_state(plugin_name, enabled):
def help_page(request):
"""Display plugin development help page"""
mailUtilities.checkHome()
proc = httpProc(request, 'pluginHolder/help.html', {}, 'admin')
proc = httpProc(request, 'pluginHolder/help.html', {}, 'managePlugins')
return proc.render()
def installed(request):
@@ -580,7 +612,7 @@ def installed(request):
proc = httpProc(request, 'pluginHolder/plugins.html',
{'plugins': pluginList, 'error_plugins': errorPlugins,
'installed_count': installed_count, 'active_count': active_count,
'cache_expiry_timestamp': cache_expiry_timestamp}, 'admin')
'cache_expiry_timestamp': cache_expiry_timestamp}, 'managePlugins')
return proc.render()
@csrf_exempt
@@ -588,6 +620,8 @@ def installed(request):
def install_plugin(request, plugin_name):
"""Install a plugin"""
try:
if not user_can_manage_plugins(request):
return deny_plugin_manage_json_response(request)
# Check if plugin source exists (in any configured source path)
pluginSource = _get_plugin_source_path(plugin_name)
if not pluginSource:
@@ -638,13 +672,11 @@ def install_plugin(request, plugin_name):
zip_path_abs = os.path.abspath(zip_path)
if not os.path.exists(zip_path_abs):
raise Exception(f'Zip file not found: {zip_path_abs}')
original_cwd = os.getcwd()
os.chdir(temp_dir)
try:
# Install using pluginInstaller with explicit zip path (avoids cwd races)
# Install using pluginInstaller (zip_path kw when supported; else legacy CWD + pluginName.zip)
try:
pluginInstaller.installPlugin(plugin_name, zip_path=zip_path_abs)
_install_plugin_compat(plugin_name, zip_path_abs)
except Exception as install_error:
# Log the full error for debugging
error_msg = str(install_error)
@@ -680,7 +712,6 @@ def install_plugin(request, plugin_name):
'message': f'Plugin {plugin_name} installed successfully'
})
finally:
os.chdir(original_cwd)
# Cleanup
shutil.rmtree(temp_dir, ignore_errors=True)
@@ -696,6 +727,8 @@ def install_plugin(request, plugin_name):
def uninstall_plugin(request, plugin_name):
"""Uninstall a plugin - but keep source files and settings"""
try:
if not user_can_manage_plugins(request):
return deny_plugin_manage_json_response(request)
# Check if plugin is installed
pluginInstalled = '/usr/local/CyberCP/' + plugin_name
if not os.path.exists(pluginInstalled):
@@ -750,6 +783,8 @@ def uninstall_plugin(request, plugin_name):
def enable_plugin(request, plugin_name):
"""Enable a plugin"""
try:
if not user_can_manage_plugins(request):
return deny_plugin_manage_json_response(request)
# Check if plugin is installed
pluginInstalled = '/usr/local/CyberCP/' + plugin_name
if not os.path.exists(pluginInstalled):
@@ -782,6 +817,8 @@ def enable_plugin(request, plugin_name):
def disable_plugin(request, plugin_name):
"""Disable a plugin"""
try:
if not user_can_manage_plugins(request):
return deny_plugin_manage_json_response(request)
# Check if plugin is installed
pluginInstalled = '/usr/local/CyberCP/' + plugin_name
if not os.path.exists(pluginInstalled):
@@ -1366,6 +1403,8 @@ def _fetch_plugins_from_github():
def fetch_plugin_store(request):
"""Fetch plugins from the plugin store with caching"""
try:
if not user_can_manage_plugins(request):
return deny_plugin_manage_json_response(request)
mailUtilities.checkHome()
except Exception as e:
logging.writeToFile(f"fetch_plugin_store: checkHome failed: {str(e)}")
@@ -1433,6 +1472,8 @@ def upgrade_plugin(request, plugin_name):
mailUtilities.checkHome()
try:
if not user_can_manage_plugins(request):
return deny_plugin_manage_json_response(request)
# Check if plugin is installed
pluginInstalled = '/usr/local/CyberCP/' + plugin_name
if not os.path.exists(pluginInstalled):
@@ -1520,55 +1561,50 @@ def upgrade_plugin(request, plugin_name):
zip_path_abs = os.path.abspath(zip_path)
if not os.path.exists(zip_path_abs):
raise Exception(f'Zip file not found: {zip_path_abs}')
original_cwd = os.getcwd()
os.chdir(temp_dir)
logging.writeToFile(f"Upgrading plugin using pluginInstaller (zip={zip_path_abs})")
# Install using pluginInstaller (zip_path kw when supported; else legacy)
try:
logging.writeToFile(f"Upgrading plugin using pluginInstaller (zip={zip_path_abs})")
# Install using pluginInstaller with explicit zip path (this will overwrite existing files)
try:
pluginInstaller.installPlugin(plugin_name, zip_path=zip_path_abs)
except Exception as install_error:
error_msg = str(install_error)
logging.writeToFile(f"pluginInstaller.installPlugin raised exception: {error_msg}")
# Check if plugin directory exists despite the error
if not os.path.exists(pluginInstalled):
raise Exception(f'Plugin upgrade failed: {error_msg}')
# Wait for file system to sync
import time
time.sleep(3)
# Verify plugin was upgraded
_install_plugin_compat(plugin_name, zip_path_abs)
except Exception as install_error:
error_msg = str(install_error)
logging.writeToFile(f"pluginInstaller.installPlugin raised exception: {error_msg}")
# Check if plugin directory exists despite the error
if not os.path.exists(pluginInstalled):
raise Exception(f'Plugin upgrade failed: {pluginInstalled} does not exist after upgrade')
# Sync meta.xml from GitHub raw so version matches store (archive ZIP can be cached/stale)
raise Exception(f'Plugin upgrade failed: {error_msg}')
# Wait for file system to sync
import time
time.sleep(3)
# Verify plugin was upgraded
if not os.path.exists(pluginInstalled):
raise Exception(f'Plugin upgrade failed: {pluginInstalled} does not exist after upgrade')
# Sync meta.xml from GitHub raw so version matches store (archive ZIP can be cached/stale)
_sync_meta_xml_from_github(plugin_name, '/usr/local/CyberCP')
new_version = _get_installed_version(plugin_name, '/usr/local/CyberCP')
# If version unchanged, meta sync may have failed (e.g. network); retry once
if new_version == installed_version:
logging.writeToFile(f"Plugin {plugin_name}: version unchanged after first meta sync, retrying sync")
_sync_meta_xml_from_github(plugin_name, '/usr/local/CyberCP')
new_version = _get_installed_version(plugin_name, '/usr/local/CyberCP')
# If version unchanged, meta sync may have failed (e.g. network); retry once
if new_version == installed_version:
logging.writeToFile(f"Plugin {plugin_name}: version unchanged after first meta sync, retrying sync")
_sync_meta_xml_from_github(plugin_name, '/usr/local/CyberCP')
new_version = _get_installed_version(plugin_name, '/usr/local/CyberCP')
if new_version == installed_version:
logging.writeToFile(f"Plugin {plugin_name}: version still {installed_version} after upgrade; meta.xml may not have been updated from GitHub")
logging.writeToFile(f"Plugin {plugin_name} upgraded successfully from {installed_version} to {new_version}")
backup_message = ''
if backup_path:
backup_message = f' Backup created at: {backup_info.get("timestamp", "unknown")}'
return JsonResponse({
'success': True,
'message': f'Plugin {plugin_name} upgraded successfully from {installed_version} to {new_version}.{backup_message}',
'backup_created': backup_path is not None,
'backup_path': backup_path if backup_path else None
})
finally:
os.chdir(original_cwd)
if new_version == installed_version:
logging.writeToFile(f"Plugin {plugin_name}: version still {installed_version} after upgrade; meta.xml may not have been updated from GitHub")
logging.writeToFile(f"Plugin {plugin_name} upgraded successfully from {installed_version} to {new_version}")
backup_message = ''
if backup_path:
backup_message = f' Backup created at: {backup_info.get("timestamp", "unknown")}'
return JsonResponse({
'success': True,
'message': f'Plugin {plugin_name} upgraded successfully from {installed_version} to {new_version}.{backup_message}',
'backup_created': backup_path is not None,
'backup_path': backup_path if backup_path else None
})
finally:
# Cleanup
@@ -1600,6 +1636,8 @@ def get_plugin_backups(request, plugin_name):
mailUtilities.checkHome()
try:
if not user_can_manage_plugins(request):
return deny_plugin_manage_json_response(request)
backups = _get_plugin_backups(plugin_name)
return JsonResponse({
'success': True,
@@ -1620,6 +1658,8 @@ def revert_plugin(request, plugin_name):
mailUtilities.checkHome()
try:
if not user_can_manage_plugins(request):
return deny_plugin_manage_json_response(request)
# Get backup path from request
data = json.loads(request.body)
backup_path = data.get('backup_path')
@@ -1684,6 +1724,8 @@ def install_from_store(request, plugin_name):
mailUtilities.checkHome()
try:
if not user_can_manage_plugins(request):
return deny_plugin_manage_json_response(request)
# Check if already installed
pluginInstalled = '/usr/local/CyberCP/' + plugin_name
if os.path.exists(pluginInstalled):
@@ -1792,55 +1834,50 @@ def install_from_store(request, plugin_name):
# Pass absolute path so extraction does not depend on cwd (installPlugin may change cwd)
zip_path_abs = os.path.abspath(zip_path)
original_cwd = os.getcwd()
os.chdir(temp_dir)
logging.writeToFile(f"Installing plugin using pluginInstaller (zip={zip_path_abs})")
# Install using pluginInstaller (zip_path kw when supported; else legacy)
try:
logging.writeToFile(f"Installing plugin using pluginInstaller (zip={zip_path_abs})")
# Install using pluginInstaller with explicit zip path (avoids cwd races)
try:
pluginInstaller.installPlugin(plugin_name, zip_path=zip_path_abs)
except Exception as install_error:
# Log the full error for debugging
error_msg = str(install_error)
logging.writeToFile(f"pluginInstaller.installPlugin raised exception: {error_msg}")
# Check if plugin directory exists despite the error
pluginInstalled = '/usr/local/CyberCP/' + plugin_name
if os.path.exists(pluginInstalled):
logging.writeToFile(f"Plugin directory exists despite error, continuing...")
else:
raise Exception(f'Plugin installation failed: {error_msg}')
# Wait a moment for file system to sync and service to restart
import time
time.sleep(3) # Increased wait time for file system sync
# Verify plugin was actually installed
_install_plugin_compat(plugin_name, zip_path_abs)
except Exception as install_error:
# Log the full error for debugging
error_msg = str(install_error)
logging.writeToFile(f"pluginInstaller.installPlugin raised exception: {error_msg}")
# Check if plugin directory exists despite the error
pluginInstalled = '/usr/local/CyberCP/' + plugin_name
if not os.path.exists(pluginInstalled):
# Exclude README.md - main CyberPanel repo has it at root
root_files = ['apps.py', 'meta.xml', 'urls.py', 'views.py']
found_root_files = [f for f in root_files if os.path.exists(os.path.join('/usr/local/CyberCP', f))]
if found_root_files:
raise Exception(f'Plugin installation failed: Files extracted to wrong location. Found {found_root_files} in /usr/local/CyberCP/ root instead of {pluginInstalled}/')
raise Exception(f'Plugin installation failed: {pluginInstalled} does not exist after installation')
# Sync meta.xml from GitHub raw so version matches store
_sync_meta_xml_from_github(plugin_name, '/usr/local/CyberCP')
logging.writeToFile(f"Plugin {plugin_name} installed successfully")
# Set plugin to enabled by default after installation
_set_plugin_state(plugin_name, True)
_ensure_plugin_meta_xml(plugin_name)
return JsonResponse({
'success': True,
'message': f'Plugin {plugin_name} installed successfully from store'
})
finally:
os.chdir(original_cwd)
if os.path.exists(pluginInstalled):
logging.writeToFile(f"Plugin directory exists despite error, continuing...")
else:
raise Exception(f'Plugin installation failed: {error_msg}')
# Wait a moment for file system to sync and service to restart
import time
time.sleep(3) # Increased wait time for file system sync
# Verify plugin was actually installed
pluginInstalled = '/usr/local/CyberCP/' + plugin_name
if not os.path.exists(pluginInstalled):
# Exclude README.md - main CyberPanel repo has it at root
root_files = ['apps.py', 'meta.xml', 'urls.py', 'views.py']
found_root_files = [f for f in root_files if os.path.exists(os.path.join('/usr/local/CyberCP', f))]
if found_root_files:
raise Exception(f'Plugin installation failed: Files extracted to wrong location. Found {found_root_files} in /usr/local/CyberCP/ root instead of {pluginInstalled}/')
raise Exception(f'Plugin installation failed: {pluginInstalled} does not exist after installation')
# Sync meta.xml from GitHub raw so version matches store
_sync_meta_xml_from_github(plugin_name, '/usr/local/CyberCP')
logging.writeToFile(f"Plugin {plugin_name} installed successfully")
# Set plugin to enabled by default after installation
_set_plugin_state(plugin_name, True)
_ensure_plugin_meta_xml(plugin_name)
return JsonResponse({
'success': True,
'message': f'Plugin {plugin_name} installed successfully from store'
})
finally:
# Cleanup
@@ -1870,6 +1907,8 @@ def install_from_store(request, plugin_name):
def debug_loaded_plugins(request):
"""Return which plugins have URL routes loaded and which failed (for diagnosing 404s)."""
try:
if not user_can_manage_plugins(request):
return deny_plugin_manage_json_response(request)
import pluginHolder.urls as urls_mod
loaded = list(getattr(urls_mod, '_loaded_plugins', []))
failed = dict(getattr(urls_mod, '_failed_plugins', {}))
@@ -1891,6 +1930,9 @@ def plugin_settings_proxy(request, plugin_name):
the plugin was installed after the worker started (dynamic URL list is built at import time).
"""
mailUtilities.checkHome()
if not user_can_manage_plugins(request):
from django.http import HttpResponseForbidden
return HttpResponseForbidden('You are not authorized to manage plugins.')
plugin_path = '/usr/local/CyberCP/' + plugin_name
urls_py = os.path.join(plugin_path, 'urls.py')
if not plugin_name or not os.path.isdir(plugin_path) or not os.path.exists(urls_py):
@@ -1927,7 +1969,7 @@ def plugin_help(request, plugin_name):
if not os.path.exists(plugin_path) or not os.path.exists(meta_xml_path):
proc = httpProc(request, 'pluginHolder/plugin_not_found.html', {
'plugin_name': plugin_name
}, 'admin')
}, 'managePlugins')
return proc.render()
# Parse meta.xml
@@ -1949,7 +1991,7 @@ def plugin_help(request, plugin_name):
logging.writeToFile(f"Error parsing meta.xml for {plugin_name}: {str(e)}")
proc = httpProc(request, 'pluginHolder/plugin_not_found.html', {
'plugin_name': plugin_name
}, 'admin')
}, 'managePlugins')
return proc.render()
# Look for help content files (README.md, CHANGELOG.md, HELP.md, etc.)
@@ -2100,7 +2142,7 @@ def plugin_help(request, plugin_name):
'help_content': help_content,
}
proc = httpProc(request, 'pluginHolder/plugin_help.html', context, 'admin')
proc = httpProc(request, 'pluginHolder/plugin_help.html', context, 'managePlugins')
return proc.render()
@csrf_exempt
@@ -2122,6 +2164,8 @@ def check_plugin_subscription(request, plugin_name):
}
"""
try:
if not user_can_manage_plugins(request):
return deny_plugin_manage_json_response(request)
# Check if user is authenticated
if not request.user or not request.user.is_authenticated:
return JsonResponse({

View File

@@ -4,6 +4,7 @@ import subprocess
import shlex
import argparse
import os
import errno
import shutil
import time
import tempfile
@@ -57,6 +58,68 @@ class pluginInstaller:
pluginHome = '/usr/local/CyberCP/' + pluginName
return os.path.exists(pluginHome + '/enable_migrations')
@staticmethod
def _write_lines_to_protected_file(target_path, lines):
"""
Write UTF-8 lines to a file. Core panel files are often root:root 644; the panel
process may need a privileged copy (lscpd/sudo) to update them.
"""
try:
with open(target_path, 'w', encoding='utf-8') as wf:
wf.writelines(lines)
return
except (PermissionError, OSError) as e:
pluginInstaller.stdOut('Direct write failed for %s: %s' % (target_path, str(e)))
fd, tmp_path = tempfile.mkstemp(prefix='cpwr_', suffix='.txt', dir='/tmp')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as wf:
wf.writelines(lines)
cmd = 'cp %s %s' % (shlex.quote(tmp_path), shlex.quote(target_path))
if ProcessUtilities.executioner(cmd) == 1:
pluginInstaller.stdOut('Wrote %s via privileged copy' % target_path)
return
except Exception as ex:
pluginInstaller.stdOut('Privileged write failed: %s' % str(ex))
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
raise PermissionError(
'Cannot write %s. As root: chgrp lscpd %s && chmod 664 %s'
% (target_path, target_path, target_path)
)
@staticmethod
def _read_lines_from_protected_file(source_path):
"""
Read UTF-8 lines from a core panel file. If the panel user cannot read the file
(e.g. root:root 600), copy via ProcessUtilities.executioner to /tmp then read.
"""
try:
with open(source_path, 'r', encoding='utf-8') as rf:
return rf.readlines()
except OSError as e:
if e.errno not in (errno.EACCES, errno.EPERM):
raise
pluginInstaller.stdOut('Direct read failed for %s: %s' % (source_path, str(e)))
fd, tmp_path = tempfile.mkstemp(prefix='cprd_', suffix='.txt', dir='/tmp')
os.close(fd)
try:
cmd = 'cp %s %s' % (shlex.quote(source_path), shlex.quote(tmp_path))
if ProcessUtilities.executioner(cmd) != 1:
raise PermissionError(
'Privileged read copy failed for %s. As root: chgrp lscpd %s && chmod 640 %s'
% (source_path, source_path, source_path)
)
with open(tmp_path, 'r', encoding='utf-8') as rf:
return rf.readlines()
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
### Functions Related to plugin installation.
@staticmethod
@@ -153,53 +216,79 @@ class pluginInstaller:
@staticmethod
def upgradingSettingsFile(pluginName):
data = open("/usr/local/CyberCP/CyberCP/settings.py", 'r', encoding='utf-8').readlines()
writeToFile = open("/usr/local/CyberCP/CyberCP/settings.py", 'w', encoding='utf-8')
settings_path = "/usr/local/CyberCP/CyberCP/settings.py"
data = pluginInstaller._read_lines_from_protected_file(settings_path)
line_plugin = " '" + pluginName + "',\n"
if any(line.strip() in ("'" + pluginName + "',", '"' + pluginName + '",') for line in data):
pluginInstaller.stdOut(
'Plugin %s already listed in settings.py; skipping INSTALLED_APPS insert.' % pluginName
)
return
out = []
inserted = False
for items in data:
if items.find("'emailPremium',") > -1:
writeToFile.writelines(items)
writeToFile.writelines(" '" + pluginName + "',\n")
out.append(items)
out.append(line_plugin)
inserted = True
else:
writeToFile.writelines(items)
writeToFile.close()
out.append(items)
if not inserted:
out = []
for items in data:
if "'pluginHolder'," in items or '"pluginHolder",' in items:
out.append(items)
out.append(line_plugin)
inserted = True
else:
out.append(items)
if not inserted:
pluginInstaller.stdOut(
'Warning: no emailPremium or pluginHolder anchor in settings.py; '
'add %r to INSTALLED_APPS manually or upgrade CyberPanel (auto-sync plugins on disk).'
% pluginName
)
return
pluginInstaller._write_lines_to_protected_file(settings_path, out)
@staticmethod
def upgradingURLs(pluginName):
"""
Add plugin URL pattern to urls.py
Plugin URLs must be inserted BEFORE the generic 'plugins/' line
to ensure proper route matching (more specific routes first)
Legacy: add explicit path('plugins/<name>/', ...). Modern CyberPanel uses
pluginHolder.urls for all plugins — skip to avoid duplicate routes and root-only writes.
"""
data = open("/usr/local/CyberCP/CyberCP/urls.py", 'r', encoding='utf-8').readlines()
writeToFile = open("/usr/local/CyberCP/CyberCP/urls.py", 'w', encoding='utf-8')
urls_path = "/usr/local/CyberCP/CyberCP/urls.py"
_url_lines = pluginInstaller._read_lines_from_protected_file(urls_path)
content = ''.join(_url_lines)
if "include('pluginHolder.urls')" in content or 'include("pluginHolder.urls")' in content:
pluginInstaller.stdOut(
'pluginHolder.urls found; skipping per-plugin urls.py line for %s (dynamic routes).' % pluginName
)
return
data = content.splitlines(keepends=True)
out = []
urlPatternAdded = False
for items in data:
# Insert plugin URL BEFORE the generic 'plugins/' line
# This ensures more specific routes are matched first
if items.find("path('plugins/', include('pluginHolder.urls'))") > -1 or items.find("path(\"plugins/\", include('pluginHolder.urls'))") > -1:
if items.find("path('plugins/', include('pluginHolder.urls'))") > -1 or items.find(
"path(\"plugins/\", include('pluginHolder.urls'))"
) > -1:
if not urlPatternAdded:
writeToFile.writelines(pluginInstaller.getUrlPattern(pluginName))
out.append(pluginInstaller.getUrlPattern(pluginName))
urlPatternAdded = True
writeToFile.writelines(items)
out.append(items)
else:
writeToFile.writelines(items)
# Fallback: if 'plugins/' line not found, insert after 'manageservices'
out.append(items)
if not urlPatternAdded:
pluginInstaller.stdOut(f"Warning: 'plugins/' line not found, using fallback insertion after 'manageservices'")
writeToFile.close()
writeToFile = open("/usr/local/CyberCP/CyberCP/urls.py", 'w', encoding='utf-8')
pluginInstaller.stdOut("Warning: 'plugins/' line not found, using fallback insertion after 'manageservices'")
out = []
for items in data:
if items.find("manageservices") > -1:
writeToFile.writelines(items)
writeToFile.writelines(pluginInstaller.getUrlPattern(pluginName))
out.append(items)
out.append(pluginInstaller.getUrlPattern(pluginName))
urlPatternAdded = True
else:
writeToFile.writelines(items)
writeToFile.close()
out.append(items)
pluginInstaller._write_lines_to_protected_file(urls_path, out)
@staticmethod
def informCyberPanel(pluginName):
@@ -214,19 +303,19 @@ class pluginInstaller:
@staticmethod
def addInterfaceLink(pluginName):
data = open("/usr/local/CyberCP/baseTemplate/templates/baseTemplate/index.html", 'r', encoding='utf-8').readlines()
writeToFile = open("/usr/local/CyberCP/baseTemplate/templates/baseTemplate/index.html", 'w', encoding='utf-8')
path_html = "/usr/local/CyberCP/baseTemplate/templates/baseTemplate/index.html"
data = pluginInstaller._read_lines_from_protected_file(path_html)
out = []
for items in data:
if items.find("{# pluginsList #}") > -1:
writeToFile.writelines(items)
writeToFile.writelines(" ")
writeToFile.writelines(
'<li><a href="{% url \'' + pluginName + '\' %}" title="{% trans \'' + pluginName + '\' %}"><span>{% trans "' + pluginName + '" %}</span></a></li>\n')
out.append(items)
out.append(" ")
out.append(
'<li><a href="{% url \'' + pluginName + '\' %}" title="{% trans \'' + pluginName + '\' %}"><span>{% trans "' + pluginName + '" %}</span></a></li>\n'
)
else:
writeToFile.writelines(items)
writeToFile.close()
out.append(items)
pluginInstaller._write_lines_to_protected_file(path_html, out)
@staticmethod
def staticContent():
@@ -469,10 +558,12 @@ class pluginInstaller:
def removeFromSettings(pluginName):
settings_path = "/usr/local/CyberCP/CyberCP/settings.py"
try:
with open(settings_path, 'r', encoding='utf-8') as f:
data = f.readlines()
except (OSError, IOError) as e:
raise Exception(f'Cannot read {settings_path}: {e}. Ensure the panel user can read it.')
data = pluginInstaller._read_lines_from_protected_file(settings_path)
except (OSError, IOError, PermissionError) as e:
raise Exception(
f'Cannot read {settings_path}: {e}. Ensure the panel user can read it '
f'(e.g. chgrp lscpd {settings_path} && chmod 640 {settings_path}).'
)
in_installed_apps = False
out_lines = []
for i, items in enumerate(data):
@@ -484,9 +575,8 @@ class pluginInstaller:
continue
out_lines.append(items)
try:
with open(settings_path, 'w', encoding='utf-8') as writeToFile:
writeToFile.writelines(out_lines)
except (OSError, IOError) as e:
pluginInstaller._write_lines_to_protected_file(settings_path, out_lines)
except (OSError, IOError, PermissionError) as e:
raise Exception(
f'Cannot write {settings_path}: {e}. '
'Ensure the file is writable by the panel user (e.g. chgrp lscpd ... ; chmod g+w ...).'
@@ -496,10 +586,12 @@ class pluginInstaller:
def removeFromURLs(pluginName):
urls_path = "/usr/local/CyberCP/CyberCP/urls.py"
try:
with open(urls_path, 'r', encoding='utf-8') as f:
data = f.readlines()
except (OSError, IOError) as e:
raise Exception(f'Cannot read {urls_path}: {e}.')
data = pluginInstaller._read_lines_from_protected_file(urls_path)
except (OSError, IOError, PermissionError) as e:
raise Exception(
f'Cannot read {urls_path}: {e}. '
f'As root: chgrp lscpd {urls_path} && chmod 640 {urls_path}'
)
out_lines = []
for items in data:
if (f"plugins/{pluginName}/" in items or f"'{pluginName}.urls'" in items or f'"{pluginName}.urls"' in items or
@@ -507,9 +599,8 @@ class pluginInstaller:
continue
out_lines.append(items)
try:
with open(urls_path, 'w', encoding='utf-8') as f:
f.writelines(out_lines)
except (OSError, IOError) as e:
pluginInstaller._write_lines_to_protected_file(urls_path, out_lines)
except (OSError, IOError, PermissionError) as e:
raise Exception(f'Cannot write {urls_path}: {e}. Ensure the file is writable by the panel user (chgrp lscpd; chmod g+w).')
@staticmethod
@@ -521,15 +612,14 @@ class pluginInstaller:
@staticmethod
def removeInterfaceLink(pluginName):
data = open("/usr/local/CyberCP/baseTemplate/templates/baseTemplate/index.html", 'r', encoding='utf-8').readlines()
writeToFile = open("/usr/local/CyberCP/baseTemplate/templates/baseTemplate/index.html", 'w', encoding='utf-8')
path_html = "/usr/local/CyberCP/baseTemplate/templates/baseTemplate/index.html"
data = pluginInstaller._read_lines_from_protected_file(path_html)
out = []
for items in data:
if items.find(pluginName) > -1 and items.find('<li>') > -1:
continue
else:
writeToFile.writelines(items)
writeToFile.close()
out.append(items)
pluginInstaller._write_lines_to_protected_file(path_html, out)
@staticmethod
def removeMigrations(pluginName):

File diff suppressed because it is too large Load Diff

View File

@@ -171,14 +171,28 @@ app.controller('createUserCtr', function ($scope, $http) {
/* Java script code to modify user account */
app.controller('modifyUser', function ($scope, $http) {
app.controller('modifyUser', function ($scope, $http, $timeout) {
var qrCode = window.qr = new QRious({
element: document.getElementById('qr'),
var qrEl = document.getElementById('qr');
var qrCode = window.qr = (qrEl && typeof QRious !== 'undefined') ? new QRious({
element: qrEl,
size: 200,
value: 'QRious'
});
}) : null;
if (!qrCode && qrEl) {
try { window.qr = new QRious({ element: qrEl, size: 200, value: 'QRious' }); } catch (e) { /* ignore */ }
}
$scope.userSearch = '';
/* Prefer global set by inline script (before Angular); fallback to script tag */
var list = (typeof window.__CP_ACCT_NAMES !== 'undefined' && Array.isArray(window.__CP_ACCT_NAMES))
? window.__CP_ACCT_NAMES
: (function() {
var el = document.getElementById('acctNamesData');
if (!el || !el.textContent) return [];
try { return JSON.parse(el.textContent); } catch (e) { return []; }
})();
$scope.acctNamesList = Array.isArray(list) ? list : [];
$scope.userModificationLoading = true;
$scope.acctDetailsFetched = true;
@@ -253,9 +267,7 @@ app.controller('modifyUser', function ($scope, $http) {
$scope.formattedSecretKey = response.data.secretKey.match(/.{1,4}/g).join(' ');
// Update the QR code with new provisioning URI
qrCode.set({
value: response.data.otpauth
});
if (qrCode) qrCode.set({ value: response.data.otpauth });
// Show success message
alert('2FA secret has been successfully regenerated! Please update your authenticator app with the new QR code or secret key.');
@@ -271,20 +283,24 @@ app.controller('modifyUser', function ($scope, $http) {
// WebAuthn Functions
$scope.loadWebAuthnData = function() {
if (!$scope.accountUsername) return;
$scope.webauthnDataLoaded = false;
var url = '/webauthn/credentials/' + $scope.accountUsername + '/';
$http.get(url).then(function(response) {
if (response.data.success) {
$scope.webauthnCredentials = response.data.credentials;
$scope.webauthnCredentials = response.data.credentials || [];
$scope.webauthnEnabled = response.data.settings.enabled;
$scope.webauthnRequirePasskey = response.data.settings.require_passkey;
$scope.webauthnAllowMultiple = response.data.settings.allow_multiple_credentials;
$scope.webauthnMaxCredentials = response.data.settings.max_credentials;
$scope.canAddCredential = response.data.settings.can_add_credential;
$scope.canAddCredential = !!response.data.settings.can_add_credential;
$scope.webauthnDataLoaded = true;
} else {
$scope.canAddCredential = true;
}
}, function(error) {
console.error('Error loading WebAuthn data:', error);
$scope.canAddCredential = true;
$scope.webauthnCredentials = [];
});
};
@@ -294,27 +310,59 @@ app.controller('modifyUser', function ($scope, $http) {
} else {
$scope.webauthnCredentials = [];
$scope.canAddCredential = true;
$scope.webauthnDataLoaded = false;
}
};
/* Inline passkey UX like diabetes.newstargeted.com/profile?tab=2fa: name input + button + message area, no modal */
$scope.newPasskeyName = '';
$scope.webauthnMessage = '';
$scope.webauthnMessageError = false;
$scope.registerPasskeyLoading = false;
$scope.registerNewPasskey = function() {
if (!window.cyberPanelWebAuthn) {
alert('WebAuthn is not supported in this browser');
if (!$scope.accountUsername) {
$scope.webauthnMessage = 'Please select a user account first.';
$scope.webauthnMessageError = true;
return;
}
var credentialName = prompt('Enter a name for this passkey:', 'Passkey ' + new Date().toLocaleDateString());
if (!credentialName) return;
window.cyberPanelWebAuthn.registerPasskey($scope.accountUsername, credentialName)
if (typeof window.cyberPanelWebAuthn === 'undefined') {
$scope.webauthnMessage = 'WebAuthn script not loaded. Refresh the page (Ctrl+F5) and try again.';
$scope.webauthnMessageError = true;
return;
}
if (typeof window.cyberPanelWebAuthn.isSupported !== 'function' || !window.cyberPanelWebAuthn.isSupported()) {
$scope.webauthnMessage = 'WebAuthn is not supported in this browser.';
$scope.webauthnMessageError = true;
return;
}
var name = ($scope.newPasskeyName || '').trim() || 'Security key';
$scope.webauthnMessage = '';
$scope.webauthnMessageError = false;
$scope.registerPasskeyLoading = true;
var username = $scope.accountUsername;
window.cyberPanelWebAuthn.registerPasskey(username, name, { silent: true })
.then(function(response) {
if (response.success) {
$scope.loadWebAuthnData();
$scope.$apply();
if (response && response.success) {
$scope.webauthnMessage = 'Passkey registered successfully.';
$scope.webauthnMessageError = false;
$scope.newPasskeyName = '';
$timeout(function() { $scope.loadWebAuthnData(); }, 0);
} else {
$scope.webauthnMessage = (response && response.error) ? response.error : 'Registration failed.';
$scope.webauthnMessageError = true;
}
})
.catch(function(error) {
var msg = (error && error.message) ? error.message : 'Passkey registration failed.';
if (error && error.name === 'NotAllowedError') msg = 'Registration was cancelled or timed out.';
$scope.webauthnMessage = msg;
$scope.webauthnMessageError = true;
console.error('Error registering passkey:', error);
})
.finally(function() {
$scope.registerPasskeyLoading = false;
if (!$scope.$$phase && !$scope.$root.$$phase) { try { $scope.$apply(); } catch (e) { /* already applied */ } }
});
};
@@ -435,14 +483,11 @@ app.controller('modifyUser', function ($scope, $http) {
$scope.webauthnTimeout = 60;
$scope.webauthnCredentials = [];
$scope.canAddCredential = true;
$scope.webauthnDataLoaded = false;
// Load WebAuthn settings and credentials
$scope.loadWebAuthnData();
qrCode.set({
value: userDetails.otpauth
});
if (qrCode) qrCode.set({ value: userDetails.otpauth });
$scope.userModificationLoading = true;
$scope.acctDetailsFetched = false;
@@ -537,8 +582,8 @@ app.controller('modifyUser', function ($scope, $http) {
$http.post(url, data, config).then(function(response) {
ListInitialDatas(response);
// Save WebAuthn settings after successful user modification
if (response.data.saveStatus == 1) {
// Save WebAuthn settings after successful user modification (only if WebAuthn script loaded)
if (response.data.saveStatus == 1 && window.cyberPanelWebAuthn) {
$scope.saveWebAuthnSettings();
}
}, cantLoadInitialDatas);
@@ -554,7 +599,7 @@ app.controller('modifyUser', function ($scope, $http) {
$scope.userModified = false;
$scope.canotModifyUser = false; // hide modify error on success
$scope.couldNotConnect = true;
$scope.canotFetchDetails = true;
$scope.canotFetchDetails = false; // hide "Cannot fetch details" on save success
$scope.detailsFetched = true;
$scope.userAccountsLimit = true;
$scope.accountTypeView = true;
@@ -592,7 +637,7 @@ app.controller('modifyUser', function ($scope, $http) {
$scope.couldNotConnect = false;
$scope.canotFetchDetails = true;
$scope.detailsFetched = true;
$scope.errorMessage = (response && response.data && (response.data.error_message || response.data.message || response.data.errorMessage)) || 'Unknown error';
}
@@ -1934,7 +1979,11 @@ app.controller('listTableUsers', function ($scope, $http) {
var UserToDelete;
$scope.populateCurrentRecords = function () {
/**
* Reload the user table from the server.
* @param {boolean} [suppressSuccessNotify=false] - When true, no success toast (avoids double popups after delete/edit/suspend when the caller already notified).
*/
$scope.populateCurrentRecords = function (suppressSuccessNotify) {
$scope.cyberpanelLoading = false;
url = "/users/fetchTableUsers";
@@ -1958,11 +2007,13 @@ app.controller('listTableUsers', function ($scope, $http) {
$scope.records = JSON.parse(response.data.data);
safePNotify({
title: 'Success!',
text: 'Users successfully fetched!',
type: 'success'
});
if (!suppressSuccessNotify) {
safePNotify({
title: 'Success!',
text: 'Users successfully fetched!',
type: 'success'
});
}
} else {
safePNotify({
@@ -1984,7 +2035,8 @@ app.controller('listTableUsers', function ($scope, $http) {
}
};
$scope.populateCurrentRecords();
/* Initial load: silent (no "fetched" toast on every page open) */
$scope.populateCurrentRecords(true);
$scope.deleteUserInitial = function (name){
@@ -2014,7 +2066,7 @@ app.controller('listTableUsers', function ($scope, $http) {
function ListInitialDatas(response) {
$scope.cyberpanelLoading = true;
if (response.data.deleteStatus === 1) {
$scope.populateCurrentRecords();
$scope.populateCurrentRecords(true);
hideModalById('deleteModal');
safePNotify({
title: 'Success!',
@@ -2078,7 +2130,7 @@ app.controller('listTableUsers', function ($scope, $http) {
function ListInitialDatas(response) {
if (response.data.status === 1) {
$scope.populateCurrentRecords();
$scope.populateCurrentRecords(true);
hideModalById('editModal');
safePNotify({
title: 'Success!',
@@ -2132,7 +2184,7 @@ app.controller('listTableUsers', function ($scope, $http) {
$scope.cyberpanelLoading = true;
if (response.data.status === 1) {
$scope.populateCurrentRecords();
$scope.populateCurrentRecords(true);
hideModalById('editModal');
safePNotify({
title: 'Success!',
@@ -2186,7 +2238,7 @@ app.controller('listTableUsers', function ($scope, $http) {
function ListInitialDatas(response) {
$scope.cyberpanelLoading = true;
if (response.data.status === 1) {
$scope.populateCurrentRecords();
$scope.populateCurrentRecords(true);
safePNotify({
title: 'Success!',
text: 'Action successfully started.',

View File

@@ -51,7 +51,7 @@ class HomeDirectoryManager:
return home_dirs
except Exception as e:
logging.CyberCPLogFileWriter.writeToFile(f"Error detecting home directories: {str(e)}")
logging.writeToFile(f"Error detecting home directories: {str(e)}")
return []
@staticmethod
@@ -103,7 +103,7 @@ class HomeDirectoryManager:
return home_dirs[0]['path']
except Exception as e:
logging.CyberCPLogFileWriter.writeToFile(f"Error selecting best home directory: {str(e)}")
logging.writeToFile(f"Error selecting best home directory: {str(e)}")
return '/home'
@staticmethod
@@ -134,7 +134,7 @@ class HomeDirectoryManager:
return True
except Exception as e:
logging.CyberCPLogFileWriter.writeToFile(f"Error creating user directory: {str(e)}")
logging.writeToFile(f"Error creating user directory: {str(e)}")
return False
@staticmethod
@@ -154,7 +154,7 @@ class HomeDirectoryManager:
return True
except Exception as e:
logging.CyberCPLogFileWriter.writeToFile(f"Error setting ownership: {str(e)}")
logging.writeToFile(f"Error setting ownership: {str(e)}")
return False
@staticmethod
@@ -188,7 +188,7 @@ class HomeDirectoryManager:
return True, "User migrated successfully"
except Exception as e:
logging.CyberCPLogFileWriter.writeToFile(f"Error migrating user: {str(e)}")
logging.writeToFile(f"Error migrating user: {str(e)}")
return False, str(e)
@staticmethod
@@ -217,7 +217,7 @@ class HomeDirectoryManager:
return stats
except Exception as e:
logging.CyberCPLogFileWriter.writeToFile(f"Error getting home directory stats: {str(e)}")
logging.writeToFile(f"Error getting home directory stats: {str(e)}")
return None
@staticmethod

View File

@@ -35,7 +35,7 @@ def loadHomeDirectoryManagement(request):
return proc.render()
except Exception as e:
logging.CyberCPLogFileWriter.writeToFile(f"Error loading home directory management: {str(e)}")
logging.writeToFile(f"Error loading home directory management: {str(e)}")
return ACLManager.loadError()
def detectHomeDirectories(request):
@@ -71,7 +71,7 @@ def detectHomeDirectories(request):
})
except Exception as e:
logging.CyberCPLogFileWriter.writeToFile(f"Error detecting home directories: {str(e)}")
logging.writeToFile(f"Error detecting home directories: {str(e)}")
return JsonResponse({'status': 0, 'error_message': str(e)})
def updateHomeDirectory(request):
@@ -109,7 +109,7 @@ def updateHomeDirectory(request):
return JsonResponse({'status': 0, 'error_message': 'Home directory not found'})
except Exception as e:
logging.CyberCPLogFileWriter.writeToFile(f"Error updating home directory: {str(e)}")
logging.writeToFile(f"Error updating home directory: {str(e)}")
return JsonResponse({'status': 0, 'error_message': str(e)})
def deleteHomeDirectory(request):
@@ -149,7 +149,7 @@ def deleteHomeDirectory(request):
return JsonResponse({'status': 0, 'error_message': 'Home directory not found'})
except Exception as e:
logging.CyberCPLogFileWriter.writeToFile(f"Error deleting home directory: {str(e)}")
logging.writeToFile(f"Error deleting home directory: {str(e)}")
return JsonResponse({'status': 0, 'error_message': str(e)})
def getHomeDirectoryStats(request):
@@ -165,7 +165,7 @@ def getHomeDirectoryStats(request):
return JsonResponse({'status': 1, 'stats': stats})
except Exception as e:
logging.CyberCPLogFileWriter.writeToFile(f"Error getting home directory stats: {str(e)}")
logging.writeToFile(f"Error getting home directory stats: {str(e)}")
return JsonResponse({'status': 0, 'error_message': str(e)})
def getUserHomeDirectories(request):
@@ -174,7 +174,9 @@ def getUserHomeDirectories(request):
userID = request.session['userID']
currentACL = ACLManager.loadedACL(userID)
if currentACL['admin'] != 1 and currentACL['createNewUser'] != 1:
# Same visibility as create user page: admins, ACL editors, and users allowed to create accounts
if (currentACL['admin'] != 1 and currentACL['createNewUser'] != 1
and currentACL.get('changeUserACL', 0) != 1):
return JsonResponse({'status': 0, 'error_message': 'Unauthorized access'})
# Get active home directories (tables home_directories / user_home_mappings may not exist yet)
@@ -195,7 +197,7 @@ def getUserHomeDirectories(request):
return JsonResponse({'status': 1, 'directories': directories})
except Exception as e:
logging.CyberCPLogFileWriter.writeToFile(f"Error getting user home directories: {str(e)}")
logging.writeToFile(f"Error getting user home directories: {str(e)}")
# If tables don't exist (e.g. user_home_mappings), return empty list so Modify Website still works
return JsonResponse({'status': 1, 'directories': []})
@@ -251,5 +253,5 @@ def migrateUser(request):
return JsonResponse({'status': 0, 'error_message': 'Target home directory not found'})
except Exception as e:
logging.CyberCPLogFileWriter.writeToFile(f"Error migrating user: {str(e)}")
logging.writeToFile(f"Error migrating user: {str(e)}")
return JsonResponse({'status': 0, 'error_message': str(e)})

View File

@@ -773,6 +773,7 @@ app.controller('createACLCTRL', function ($scope, $http) {
//
$scope.versionManagement = false;
$scope.managePlugins = false;
// User Management
@@ -854,6 +855,7 @@ app.controller('createACLCTRL', function ($scope, $http) {
//
versionManagement: $scope.versionManagement,
managePlugins: $scope.managePlugins,
// User Management
@@ -977,6 +979,7 @@ app.controller('createACLCTRL', function ($scope, $http) {
//
$scope.versionManagement = true;
$scope.managePlugins = true;
// User Management
@@ -1048,6 +1051,7 @@ app.controller('createACLCTRL', function ($scope, $http) {
//
$scope.versionManagement = false;
$scope.managePlugins = false;
// User Management
@@ -1232,6 +1236,7 @@ app.controller('modifyACLCtrl', function ($scope, $http) {
//
$scope.versionManagement = Boolean(response.data.versionManagement);
$scope.managePlugins = Boolean(response.data.managePlugins);
// User Management
@@ -1333,6 +1338,7 @@ app.controller('modifyACLCtrl', function ($scope, $http) {
adminStatus: $scope.makeAdmin,
//
versionManagement: $scope.versionManagement,
managePlugins: $scope.managePlugins,
// User Management
@@ -1456,6 +1462,7 @@ app.controller('modifyACLCtrl', function ($scope, $http) {
//
$scope.versionManagement = true;
$scope.managePlugins = true;
// User Management
@@ -1527,6 +1534,7 @@ app.controller('modifyACLCtrl', function ($scope, $http) {
//
$scope.versionManagement = false;
$scope.managePlugins = false;
// User Management
@@ -1979,7 +1987,11 @@ app.controller('listTableUsers', function ($scope, $http) {
var UserToDelete;
$scope.populateCurrentRecords = function () {
/**
* Reload the user table from the server.
* @param {boolean} [suppressSuccessNotify=false] - When true, no success toast (avoids double popups after delete/edit/suspend when the caller already notified).
*/
$scope.populateCurrentRecords = function (suppressSuccessNotify) {
$scope.cyberpanelLoading = false;
url = "/users/fetchTableUsers";
@@ -2003,11 +2015,13 @@ app.controller('listTableUsers', function ($scope, $http) {
$scope.records = JSON.parse(response.data.data);
safePNotify({
title: 'Success!',
text: 'Users successfully fetched!',
type: 'success'
});
if (!suppressSuccessNotify) {
safePNotify({
title: 'Success!',
text: 'Users successfully fetched!',
type: 'success'
});
}
} else {
safePNotify({
@@ -2029,7 +2043,8 @@ app.controller('listTableUsers', function ($scope, $http) {
}
};
$scope.populateCurrentRecords();
/* Initial load: silent (no "fetched" toast on every page open) */
$scope.populateCurrentRecords(true);
$scope.deleteUserInitial = function (name){
@@ -2059,7 +2074,7 @@ app.controller('listTableUsers', function ($scope, $http) {
function ListInitialDatas(response) {
$scope.cyberpanelLoading = true;
if (response.data.deleteStatus === 1) {
$scope.populateCurrentRecords();
$scope.populateCurrentRecords(true);
hideModalById('deleteModal');
safePNotify({
title: 'Success!',
@@ -2123,7 +2138,7 @@ app.controller('listTableUsers', function ($scope, $http) {
function ListInitialDatas(response) {
if (response.data.status === 1) {
$scope.populateCurrentRecords();
$scope.populateCurrentRecords(true);
hideModalById('editModal');
safePNotify({
title: 'Success!',
@@ -2177,7 +2192,7 @@ app.controller('listTableUsers', function ($scope, $http) {
$scope.cyberpanelLoading = true;
if (response.data.status === 1) {
$scope.populateCurrentRecords();
$scope.populateCurrentRecords(true);
hideModalById('editModal');
safePNotify({
title: 'Success!',
@@ -2231,7 +2246,7 @@ app.controller('listTableUsers', function ($scope, $http) {
function ListInitialDatas(response) {
$scope.cyberpanelLoading = true;
if (response.data.status === 1) {
$scope.populateCurrentRecords();
$scope.populateCurrentRecords(true);
safePNotify({
title: 'Success!',
text: 'Action successfully started.',

View File

@@ -162,6 +162,9 @@
<div class="checkbox-wrapper">
<label><input ng-model="versionManagement" type="checkbox" value=""> {% trans "Version Management" %}</label>
</div>
<div class="checkbox-wrapper">
<label><input ng-model="managePlugins" type="checkbox" value=""> {% trans "Plugin management" %} ({% trans "Plugin Store and installed plugins" %})</label>
</div>
<div class="checkbox-wrapper">
<div class="form-label" style="margin-bottom: 0.5rem;">{% trans "User Management" %}</div>
<label><input ng-model="createNewUser" type="checkbox" value=""> {% trans "Create New User" %}</label>

View File

@@ -283,9 +283,11 @@
<div class="form-col-6">
<div class="form-group">
<label class="form-label">{% trans "Access Control List (ACL)" %}</label>
<select ng-model="selectedACL" class="form-control">
{# ng-init: Angular otherwise leaves selectedACL undefined until user picks an option, so POST omits ACL and user creation fails. #}
<select ng-model="selectedACL" class="form-control"
ng-init="selectedACL = selectedACL || '{{ default_acl_name|escapejs }}'">
{% for items in aclNames %}
<option>{{ items }}</option>
<option value="{{ items|escape }}">{{ items }}</option>
{% endfor %}
</select>
<p class="help-text">{% trans "Select the permission set for this user" %}</p>

View File

@@ -374,6 +374,10 @@
<input ng-model="versionManagement" type="checkbox" id="versionManagement">
<label for="versionManagement" class="checkbox-label">{% trans "Version Management" %}</label>
</div>
<div class="checkbox-wrapper">
<input ng-model="managePlugins" type="checkbox" id="managePlugins">
<label for="managePlugins" class="checkbox-label">{% trans "Plugin management" %} ({% trans "Plugin Store and installed plugins" %})</label>
</div>
</div>
</div>

View File

@@ -124,6 +124,7 @@ class TestUserManagement(TestCase):
data_ret = {'aclName': 'hello', 'makeAdmin':1,
'createNewUser': 1,
'versionManagement': 1,
'managePlugins': 0,
'listUsers': 1,
'resellerCenter': 1,
'deleteUser': 1,
@@ -190,6 +191,7 @@ class TestUserManagement(TestCase):
'adminStatus':1,
'createNewUser': 1,
'versionManagement': 1,
'managePlugins': 0,
'listUsers': 1,
'resellerCenter': 1,
'deleteUser': 1,

View File

@@ -4,6 +4,8 @@
from django.shortcuts import render, redirect
from django.http import HttpResponse
from django.db import models
from django.db.utils import IntegrityError, ProgrammingError, OperationalError
from django.views.decorators.csrf import ensure_csrf_cookie
from loginSystem.views import loadLoginPage
from loginSystem.models import Administrator, ACL
import json
@@ -50,24 +52,31 @@ def viewProfile(request):
return proc.render()
@ensure_csrf_cookie
def createUser(request):
userID = request.session['userID']
currentACL = ACLManager.loadedACL(userID)
if currentACL['admin'] == 1:
aclNames = ACLManager.unFileteredACLs()
default_acl = aclNames[0] if aclNames else 'user'
proc = httpProc(request, 'userManagment/createUser.html',
{'aclNames': aclNames, 'securityLevels': SecurityLevel.list()})
{'aclNames': aclNames, 'default_acl_name': default_acl,
'securityLevels': SecurityLevel.list()})
return proc.render()
elif currentACL['changeUserACL'] == 1:
aclNames = ACLManager.unFileteredACLs()
default_acl = aclNames[0] if aclNames else 'user'
proc = httpProc(request, 'userManagment/createUser.html',
{'aclNames': aclNames, 'securityLevels': SecurityLevel.list()})
{'aclNames': aclNames, 'default_acl_name': default_acl,
'securityLevels': SecurityLevel.list()})
return proc.render()
elif currentACL['createNewUser'] == 1:
aclNames = ['user']
default_acl = 'user'
proc = httpProc(request, 'userManagment/createUser.html',
{'aclNames': aclNames, 'securityLevels': SecurityLevel.list()})
{'aclNames': aclNames, 'default_acl_name': default_acl,
'securityLevels': SecurityLevel.list()})
return proc.render()
else:
return ACLManager.loadError()
@@ -213,8 +222,32 @@ def submitUserCreation(request):
email = data['email']
userName = data['userName']
password = data['password']
websitesLimit = data['websitesLimit']
selectedACL = data['selectedACL']
if userName is None:
userName = ''
else:
userName = str(userName).strip()
if not userName:
data_ret = {'status': 0, 'createStatus': 0,
'error_message': 'Username is required.'}
json_data = json.dumps(data_ret)
return HttpResponse(json_data, content_type='application/json')
if Administrator.objects.filter(userName=userName).exists():
data_ret = {'status': 0, 'createStatus': 0,
'error_message': 'That username is already in use. Choose a different username.'}
json_data = json.dumps(data_ret)
return HttpResponse(json_data, content_type='application/json')
try:
websitesLimit = int(data['websitesLimit'])
except (KeyError, TypeError, ValueError):
websitesLimit = 0
selectedACL = data.get('selectedACL')
if selectedACL is None or (isinstance(selectedACL, str) and not selectedACL.strip()):
data_ret = {'status': 0, 'createStatus': 0,
'error_message': 'Please select an access control list (ACL).'}
json_data = json.dumps(data_ret)
return HttpResponse(json_data, content_type='application/json')
if isinstance(selectedACL, str):
selectedACL = selectedACL.strip()
selectedHomeDirectory = data.get('selectedHomeDirectory', '')
if ACLManager.CheckRegEx("^[\w'\-,.][^0-9_!¡?÷?¿/\\+=@#$%ˆ&*(){}|~<>;:[\]]{2,}$", firstName) == 0:
@@ -239,7 +272,13 @@ def submitUserCreation(request):
except:
securityLevel = 'HIGH'
selectedACL = ACL.objects.get(name=selectedACL)
try:
selectedACL = ACL.objects.get(name=selectedACL)
except ACL.DoesNotExist:
data_ret = {'status': 0, 'createStatus': 0,
'error_message': 'The selected access level (ACL) was not found. Refresh the page and try again.'}
json_data = json.dumps(data_ret)
return HttpResponse(json_data, content_type='application/json')
if selectedACL.adminStatus == 1:
type = 1
@@ -325,34 +364,43 @@ def submitUserCreation(request):
# Handle home directory assignment
from .homeDirectoryManager import HomeDirectoryManager
from .models import HomeDirectory, UserHomeMapping
if selectedHomeDirectory:
# Use selected home directory
# Always resolve home_dir after home_path. Previously, if selectedHomeDirectory was set but
# the row was missing (DoesNotExist), we set home_path but left home_dir undefined and
# UserHomeMapping.objects.create() raised UnboundLocalError → "Failed to create user account".
home_dir = None
if selectedHomeDirectory not in (None, '', 0, '0', 'false', 'null'):
try:
home_dir = HomeDirectory.objects.get(id=selectedHomeDirectory)
home_path = home_dir.path
except HomeDirectory.DoesNotExist:
hid = int(str(selectedHomeDirectory).strip())
if hid > 0:
home_dir = HomeDirectory.objects.get(id=hid)
home_path = home_dir.path
else:
home_path = HomeDirectoryManager.getBestHomeDirectory()
except (ValueError, TypeError, HomeDirectory.DoesNotExist):
home_path = HomeDirectoryManager.getBestHomeDirectory()
else:
# Auto-select best home directory
home_path = HomeDirectoryManager.getBestHomeDirectory()
if home_dir is None:
try:
home_dir = HomeDirectory.objects.get(path=home_path)
except HomeDirectory.DoesNotExist:
# Create home directory entry if it doesn't exist
home_dir = HomeDirectory.objects.create(
name=home_path.split('/')[-1],
name=(home_path.split('/')[-1] or 'home'),
path=home_path,
is_active=True,
is_default=(home_path == '/home')
)
except HomeDirectory.MultipleObjectsReturned:
home_dir = HomeDirectory.objects.filter(path=home_path).order_by('id').first()
# Create user directory
if HomeDirectoryManager.createUserDirectory(userName, home_path):
# Create user-home mapping
UserHomeMapping.objects.create(
# Create user-home mapping (ignore duplicate if re-run)
UserHomeMapping.objects.get_or_create(
user=newAdmin,
home_directory=home_dir
defaults={'home_directory': home_dir}
)
else:
# Log error but don't fail user creation
@@ -364,11 +412,23 @@ def submitUserCreation(request):
final_json = json.dumps(data_ret)
return HttpResponse(final_json, content_type='application/json')
except IntegrityError as e:
secure_log_error(e, 'submitUserCreation', request.session.get('userID', 'Unknown'))
data_ret = {
'status': 0,
'createStatus': 0,
'error_message': 'That username is already in use. Choose a different username.',
}
json_data = json.dumps(data_ret)
return HttpResponse(json_data, content_type='application/json')
except Exception as e:
secure_log_error(e, 'submitUserCreation', request.session.get('userID', 'Unknown'))
data_ret = secure_error_response(e, 'Failed to create user account')
if isinstance(data_ret, dict):
data_ret['createStatus'] = 0
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
return HttpResponse(json_data, content_type='application/json')
except KeyError:
data_ret = {'status': 0, 'createStatus': 0, 'error_message': "Not logged in as admin", }
@@ -570,6 +630,62 @@ def deleteUser(request):
return ACLManager.loadError()
def robust_delete_administrator(admin_instance):
"""
Delete an Administrator when optional WebAuthn tables were never migrated.
ORM .delete() still collects WebAuthnCredential etc. and MySQL raises 1146 if
webauthn_credentials is missing. Fall back to SQL DELETE on the admin row
(and any WebAuthn tables that do exist) after removing child admins (owner FK is integer, not DB FK).
"""
from django.db import connection
if admin_instance is None:
return
pk = admin_instance.pk
db_table = Administrator._meta.db_table
try:
table_names = set(connection.introspection.table_names())
except Exception:
table_names = set()
webauthn_tables = (
('webauthn_credentials', 'user_id'),
('webauthn_challenges', 'user_id'),
('webauthn_sessions', 'user_id'),
('webauthn_settings', 'user_id'),
)
for child in Administrator.objects.filter(owner=pk):
robust_delete_administrator(child)
use_orm = 'webauthn_credentials' in table_names
if use_orm:
try:
admin_instance.delete()
return
except (ProgrammingError, OperationalError) as exc:
err = str(exc).lower()
if 'webauthn' not in err and "doesn't exist" not in err and 'does not exist' not in err:
raise
qn = connection.ops.quote_name
tbl = qn(db_table)
col_id = qn('id')
with connection.cursor() as cursor:
for tname, cname in webauthn_tables:
if tname in table_names:
try:
cursor.execute(
'DELETE FROM %s WHERE %s = %%s' % (qn(tname), qn(cname)),
[pk],
)
except (ProgrammingError, OperationalError):
pass
cursor.execute('DELETE FROM %s WHERE %s = %%s' % (tbl, col_id), [pk])
def submitUserDeletion(request):
try:
@@ -610,31 +726,28 @@ def submitUserDeletion(request):
user = Administrator.objects.get(userName=accountUsername)
childUsers = Administrator.objects.filter(owner=user.pk)
for items in childUsers:
items.delete()
user.delete()
robust_delete_administrator(user)
data_ret = {'status': 1, 'deleteStatus': 1, 'error_message': 'None'}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
return HttpResponse(json_data, content_type='application/json')
else:
data_ret = {'status': 0, 'deleteStatus': 0, 'error_message': 'Not enough privileges.'}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
return HttpResponse(json_data, content_type='application/json')
except Exception as e:
secure_log_error(e, 'submitUserDeletion', request.session.get('userID', 'Unknown'))
data_ret = secure_error_response(e, 'Failed to delete user account')
if isinstance(data_ret, dict):
data_ret['deleteStatus'] = 0
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
return HttpResponse(json_data, content_type='application/json')
except KeyError:
data_ret = {'deleteStatus': 0, 'error_message': "Not logged in as admin", }
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
return HttpResponse(json_data, content_type='application/json')
def createNewACL(request):
@@ -1089,7 +1202,8 @@ def userMigration(request):
return proc.render()
except Exception as e:
logging.CyberCPLogFileWriter.writeToFile(f"Error loading user migration: {str(e)}")
from plogical.CyberCPLogFileWriter import CyberCPLogFileWriter as _cp_log
_cp_log.writeToFile(f"Error loading user migration: {str(e)}")
return ACLManager.loadError()