diff --git a/CPScripts/ensure_ftp_users_quota_columns.py b/CPScripts/ensure_ftp_users_quota_columns.py new file mode 100644 index 000000000..7685e9739 --- /dev/null +++ b/CPScripts/ensure_ftp_users_quota_columns.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Idempotent schema repair: add custom_quota_enabled and custom_quota_size to the +Django ftp.Users table (Meta.db_table = 'users') if missing. + +Fixes MySQL 1054: Unknown column 'custom_quota_enabled' in 'INSERT INTO' +when creating FTP accounts after upgrading CyberPanel without a matching migration. + +Usage: + CP_DIR=/usr/local/CyberCP python3 ensure_ftp_users_quota_columns.py + python3 ensure_ftp_users_quota_columns.py /usr/local/CyberCP +""" +from __future__ import annotations + +import os +import sys + + +def main() -> int: + try: + if len(sys.argv) > 1 and sys.argv[1].strip(): + cp_dir = os.path.abspath(sys.argv[1].strip()) + else: + cp_dir = os.path.abspath(os.environ.get("CP_DIR", "/usr/local/CyberCP")) + + if not os.path.isdir(cp_dir): + sys.stderr.write("ensure_ftp_users_quota_columns: CP directory not found: %s\n" % cp_dir) + return 1 + + sys.path.insert(0, cp_dir) + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "CyberCP.settings") + + import django + + django.setup() + + from django.db import connection + + table = "users" + alters = ( + ("custom_quota_enabled", "TINYINT(1) NOT NULL DEFAULT 0"), + ("custom_quota_size", "INT NOT NULL DEFAULT 0"), + ) + + with connection.cursor() as cursor: + cursor.execute("SELECT DATABASE()") + row = cursor.fetchone() + dbname = row[0] if row else None + if not dbname: + sys.stderr.write( + "ensure_ftp_users_quota_columns: could not resolve current database name.\n" + ) + return 1 + + for col, definition in alters: + cursor.execute( + """ + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = %s + """, + [dbname, table, col], + ) + exists = cursor.fetchone()[0] > 0 + if exists: + print("ensure_ftp_users_quota_columns: column %s already on %s; skipped." % (col, table)) + continue + # identifiers are fixed literals; definition is controlled (no user input) + cursor.execute( + "ALTER TABLE `%s` ADD COLUMN `%s` %s" % (table, col, definition) + ) + print("ensure_ftp_users_quota_columns: added column %s to %s." % (col, table)) + + print("ensure_ftp_users_quota_columns: done.") + return 0 + except Exception as exc: + sys.stderr.write("ensure_ftp_users_quota_columns: error: %s\n" % (exc,)) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/deploy-ftp-users-custom-quota-columns.sh b/deploy-ftp-users-custom-quota-columns.sh new file mode 100755 index 000000000..0b5b9d9ad --- /dev/null +++ b/deploy-ftp-users-custom-quota-columns.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# Add missing custom_quota_enabled / custom_quota_size columns to ftp Users table (users). +# Fixes: (1054, "Unknown column 'custom_quota_enabled' in 'INSERT INTO'") on FTP account creation. +# +# Usage: +# sudo bash /home/cyberpanel-repo/deploy-ftp-users-custom-quota-columns.sh +# sudo bash deploy-ftp-users-custom-quota-columns.sh [REPO_DIR] [CP_DIR] + +set -e + +log() { echo "[$(date +%Y-%m-%d\ %H:%M:%S)] $*"; } +err() { log "ERROR: $*" >&2; } + +# Resolve REPO_DIR +if [[ -n "$1" && -f "$1/CPScripts/ensure_ftp_users_quota_columns.py" ]]; then + REPO_DIR="$1" + shift +elif [[ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)/CPScripts/ensure_ftp_users_quota_columns.py" ]]; then + REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +elif [[ -f "/home/cyberpanel-repo/CPScripts/ensure_ftp_users_quota_columns.py" ]]; then + REPO_DIR="/home/cyberpanel-repo" +elif [[ -f "./CPScripts/ensure_ftp_users_quota_columns.py" ]]; then + REPO_DIR="$(pwd)" +else + err "CPScripts/ensure_ftp_users_quota_columns.py not found." + exit 1 +fi + +CP_DIR="${1:-/usr/local/CyberCP}" +RESTART_LSCPD="${RESTART_LSCPD:-1}" + +if [[ ! -d "$CP_DIR" ]]; then + err "CyberPanel directory not found: $CP_DIR" + exit 1 +fi + +log "REPO_DIR=$REPO_DIR" +log "CP_DIR=$CP_DIR" + +mkdir -p "$CP_DIR/CPScripts" +cp -f "$REPO_DIR/CPScripts/ensure_ftp_users_quota_columns.py" "$CP_DIR/CPScripts/ensure_ftp_users_quota_columns.py" +chmod 644 "$CP_DIR/CPScripts/ensure_ftp_users_quota_columns.py" +log "Copied ensure_ftp_users_quota_columns.py to $CP_DIR/CPScripts/" + +log "Ensuring FTP users table has custom quota columns..." +export CP_DIR +PY="$CP_DIR/bin/python" +if [[ -x "$PY" ]]; then + "$PY" "$CP_DIR/CPScripts/ensure_ftp_users_quota_columns.py" "$CP_DIR" || { err "Python repair failed"; exit 1; } +else + python3 "$CP_DIR/CPScripts/ensure_ftp_users_quota_columns.py" "$CP_DIR" || { err "Python repair failed"; exit 1; } +fi + +if [[ "$RESTART_LSCPD" =~ ^(1|yes|true)$ ]]; then + if systemctl is-active --quiet lscpd 2>/dev/null; then + log "Restarting lscpd..." + systemctl restart lscpd || { err "lscpd restart failed"; exit 1; } + log "lscpd restarted." + else + log "lscpd not running or not a systemd service; skip restart." + fi +else + log "Skipping restart (set RESTART_LSCPD=1 to restart lscpd)." +fi + +log "Deploy complete. Test: Websites → FTP → Create FTP Account." diff --git a/ftp/ftpManager.py b/ftp/ftpManager.py index 596bae449..e3fc3404c 100644 --- a/ftp/ftpManager.py +++ b/ftp/ftpManager.py @@ -283,6 +283,7 @@ class FTPManager: 'custom_quota_enabled': items.custom_quota_enabled, 'custom_quota_size': items.custom_quota_size, 'package_quota': items.domain.package.diskSpace, + 'acct_enabled': (str(items.status) == '1'), } if checker == 0: @@ -330,6 +331,75 @@ class FTPManager: json_data = json.dumps(data_ret) return HttpResponse(json_data) + def changeFTPDirectory(self): + """ + Change FTP account home directory after creation. + Uses listFTPAccounts permission (same as password / quota). + """ + try: + userID = self.request.session['userID'] + currentACL = ACLManager.loadedACL(userID) + + if ACLManager.currentContextPermission(currentACL, 'listFTPAccounts') == 0: + return ACLManager.loadErrorJson('changeDirectoryStatus', 0) + + data = json.loads(self.request.body) + userName = data['ftpUserName'] + selectedDomain = data['selectedDomain'] + newPath = data.get('path', '') + if newPath is None: + newPath = '' + + admin = Administrator.objects.get(pk=userID) + if ACLManager.checkOwnership(selectedDomain, admin, currentACL) != 1: + return ACLManager.loadErrorJson() + + ftp = Users.objects.get(user=userName) + if currentACL['admin'] != 1 and ftp.domain.admin != admin: + return ACLManager.loadErrorJson() + + result = FTPUtilities.changeFTPDirectory(userName, newPath, selectedDomain) + if result[0] == 1: + data_ret = {'status': 1, 'changeDirectoryStatus': 1, 'error_message': 'None'} + else: + data_ret = {'status': 0, 'changeDirectoryStatus': 0, 'error_message': result[1]} + return HttpResponse(json.dumps(data_ret), content_type='application/json') + except BaseException as msg: + data_ret = {'status': 0, 'changeDirectoryStatus': 0, 'error_message': str(msg)} + return HttpResponse(json.dumps(data_ret), content_type='application/json') + + def setFTPAccountStatus(self): + """Enable (enabled=true) or disable (enabled=false) FTP login for a user.""" + try: + userID = self.request.session['userID'] + currentACL = ACLManager.loadedACL(userID) + + if ACLManager.currentContextPermission(currentACL, 'listFTPAccounts') == 0: + return ACLManager.loadErrorJson('setFTPStatusResult', 0) + + data = json.loads(self.request.body) + userName = data['ftpUserName'] + selectedDomain = data['selectedDomain'] + enabled = bool(data.get('enabled', True)) + + admin = Administrator.objects.get(pk=userID) + if ACLManager.checkOwnership(selectedDomain, admin, currentACL) != 1: + return ACLManager.loadErrorJson() + + ftp = Users.objects.get(user=userName) + if currentACL['admin'] != 1 and ftp.domain.admin != admin: + return ACLManager.loadErrorJson() + + result = FTPUtilities.setFTPAccountStatus(userName, enabled, selectedDomain) + if result[0] == 1: + data_ret = {'status': 1, 'setFTPStatusResult': 1, 'error_message': 'None'} + else: + data_ret = {'status': 0, 'setFTPStatusResult': 0, 'error_message': result[1]} + return HttpResponse(json.dumps(data_ret), content_type='application/json') + except BaseException as msg: + data_ret = {'status': 0, 'setFTPStatusResult': 0, 'error_message': str(msg)} + return HttpResponse(json.dumps(data_ret), content_type='application/json') + def updateFTPQuota(self): try: userID = self.request.session['userID'] diff --git a/ftp/pluginManager.py b/ftp/pluginManager.py index baacfe757..6b2130361 100644 --- a/ftp/pluginManager.py +++ b/ftp/pluginManager.py @@ -33,4 +33,20 @@ class pluginManager: @staticmethod def postChangePassword(request, response): - return pluginManagerGlobal.globalPlug(request, postChangePassword, response) \ No newline at end of file + return pluginManagerGlobal.globalPlug(request, postChangePassword, response) + + @staticmethod + def preChangeFTPDirectory(request): + return pluginManagerGlobal.globalPlug(request, preChangeFTPDirectory) + + @staticmethod + def postChangeFTPDirectory(request, response): + return pluginManagerGlobal.globalPlug(request, postChangeFTPDirectory, response) + + @staticmethod + def preSetFTPAccountStatus(request): + return pluginManagerGlobal.globalPlug(request, preSetFTPAccountStatus) + + @staticmethod + def postSetFTPAccountStatus(request, response): + return pluginManagerGlobal.globalPlug(request, postSetFTPAccountStatus, response) \ No newline at end of file diff --git a/ftp/signals.py b/ftp/signals.py index 874ee0421..6aa74c237 100644 --- a/ftp/signals.py +++ b/ftp/signals.py @@ -26,4 +26,11 @@ postSubmitFTPDelete = Signal() preChangePassword = Signal() ## This event is fired after CyberPanel core finished deletion of child-domain -postChangePassword = Signal() \ No newline at end of file +postChangePassword = Signal() + +## Before / after changing FTP account home directory (list FTP page) +preChangeFTPDirectory = Signal() +postChangeFTPDirectory = Signal() + +preSetFTPAccountStatus = Signal() +postSetFTPAccountStatus = Signal() \ No newline at end of file diff --git a/ftp/static/ftp/ftp.js b/ftp/static/ftp/ftp.js index ef6cd4a4e..83f715a08 100644 --- a/ftp/static/ftp/ftp.js +++ b/ftp/static/ftp/ftp.js @@ -6,14 +6,20 @@ /* Java script code to create account */ app.controller('createFTPAccount', function ($scope, $http) { - // Initialize all ng-hide variables to hide alerts on page load $scope.ftpLoading = false; $scope.ftpDetails = true; - $scope.canNotCreateFTP = true; - $scope.successfullyCreatedFTP = true; - $scope.couldNotConnect = true; + // Positive flags + ng-show: stay hidden until create flow sets them (avoids pre-bind ng-hide/undefined flash) + $scope.alertFtpCreateError = false; + $scope.alertFtpCreateSuccess = false; + $scope.alertFtpConnectFailed = false; $scope.generatedPasswordView = true; + function resetFtpCreateAlerts() { + $scope.alertFtpCreateError = false; + $scope.alertFtpCreateSuccess = false; + $scope.alertFtpConnectFailed = false; + } + $(document).ready(function () { $( ".ftpDetails, .account-details" ).hide(); $( ".ftpPasswordView" ).hide(); @@ -65,11 +71,11 @@ app.controller('createFTPAccount', function ($scope, $http) { $scope.createFTPAccount = function () { + var submissionCompleted = false; + $scope.ftpLoading = true; // Show loading while creating $scope.ftpDetails = false; - $scope.canNotCreateFTP = true; - $scope.successfullyCreatedFTP = true; - $scope.couldNotConnect = true; + resetFtpCreateAlerts(); var ftpDomain = $scope.ftpDomain; var ftpUserName = $scope.ftpUserName; @@ -89,9 +95,8 @@ app.controller('createFTPAccount', function ($scope, $http) { var dangerousChars = /[;&|$`'"<>*?~]/; if (dangerousChars.test(path)) { $scope.ftpLoading = false; - $scope.canNotCreateFTP = false; - $scope.successfullyCreatedFTP = true; - $scope.couldNotConnect = true; + resetFtpCreateAlerts(); + $scope.alertFtpCreateError = true; $scope.errorMessage = "Invalid path: Path contains dangerous characters"; return; } @@ -99,22 +104,12 @@ app.controller('createFTPAccount', function ($scope, $http) { // Check for path traversal attempts if (path.indexOf("..") !== -1 || path.indexOf("~") !== -1) { $scope.ftpLoading = false; - $scope.canNotCreateFTP = false; - $scope.successfullyCreatedFTP = true; - $scope.couldNotConnect = true; + resetFtpCreateAlerts(); + $scope.alertFtpCreateError = true; $scope.errorMessage = "Invalid path: Path cannot contain '..' or '~'"; return; } - - // Check if path starts with slash (should be relative) - if (path.startsWith("/")) { - $scope.ftpLoading = false; - $scope.canNotCreateFTP = false; - $scope.successfullyCreatedFTP = true; - $scope.couldNotConnect = true; - $scope.errorMessage = "Invalid path: Path must be relative (not starting with '/')"; - return; - } + // Absolute paths under /home/... are allowed; server validates they stay inside the site home } var url = "/ftp/submitFTPCreation"; @@ -140,20 +135,20 @@ app.controller('createFTPAccount', function ($scope, $http) { function ListInitialDatas(response) { + if (submissionCompleted) { + return; + } + submissionCompleted = true; + $scope.ftpLoading = false; + resetFtpCreateAlerts(); if (response.data && response.data.creatFTPStatus === 1) { - $scope.ftpLoading = false; // Hide loading on success - $scope.successfullyCreatedFTP = false; - $scope.canNotCreateFTP = true; - $scope.couldNotConnect = true; + $scope.alertFtpCreateSuccess = true; $scope.createdFTPUsername = (response.data.createdFTPUsername != null && response.data.createdFTPUsername !== '') ? response.data.createdFTPUsername : (ftpDomain + '_' + ftpUserName); if (typeof PNotify !== 'undefined') { new PNotify({ title: 'Success!', text: 'FTP account successfully created.', type: 'success' }); } } else { - $scope.ftpLoading = false; - $scope.canNotCreateFTP = false; - $scope.successfullyCreatedFTP = true; - $scope.couldNotConnect = true; + $scope.alertFtpCreateError = true; $scope.errorMessage = (response.data && response.data.error_message) ? response.data.error_message : 'Unknown error'; if (typeof PNotify !== 'undefined') { new PNotify({ title: 'Operation Failed!', text: $scope.errorMessage, type: 'error' }); @@ -162,14 +157,15 @@ app.controller('createFTPAccount', function ($scope, $http) { } function cantLoadInitialDatas(response) { + if (submissionCompleted) { + return; + } + submissionCompleted = true; $scope.ftpLoading = false; - if ($scope.successfullyCreatedFTP !== false) { - $scope.couldNotConnect = false; - $scope.canNotCreateFTP = true; - $scope.successfullyCreatedFTP = true; - if (typeof PNotify !== 'undefined') { - new PNotify({ title: 'Operation Failed!', text: 'Could not connect to server, please refresh this page', type: 'error' }); - } + resetFtpCreateAlerts(); + $scope.alertFtpConnectFailed = true; + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Operation Failed!', text: 'Could not connect to server, please refresh this page', type: 'error' }); } } @@ -177,9 +173,7 @@ app.controller('createFTPAccount', function ($scope, $http) { }; $scope.hideFewDetails = function () { - $scope.successfullyCreatedFTP = true; - $scope.canNotCreateFTP = true; - $scope.couldNotConnect = true; + resetFtpCreateAlerts(); }; /// @@ -373,6 +367,7 @@ app.controller('listFTPAccounts', function ($scope, $http) { $scope.ftpAccounts = true; $scope.changePasswordBox = true; $scope.quotaManagementBox = true; + $scope.directoryManagementBox = true; $scope.notificationsBox = true; var globalFTPUsername = ""; @@ -388,6 +383,8 @@ app.controller('listFTPAccounts', function ($scope, $http) { $scope.couldNotConnect = true; $scope.ftpLoading = false; // Don't show loading when opening password dialog $scope.changePasswordBox = false; + $scope.quotaManagementBox = true; + $scope.directoryManagementBox = true; $scope.notificationsBox = true; $scope.ftpUsername = ftpUsername; globalFTPUsername = ftpUsername; @@ -452,6 +449,8 @@ app.controller('listFTPAccounts', function ($scope, $http) { $scope.ftpLoading = true; // Show loading while fetching $scope.ftpAccounts = true; $scope.changePasswordBox = true; + $scope.quotaManagementBox = true; + $scope.directoryManagementBox = true; var selectedDomain = $scope.selectedDomain; @@ -477,7 +476,11 @@ app.controller('listFTPAccounts', function ($scope, $http) { if (response.data.fetchStatus == 1) { $scope.records = JSON.parse(response.data.data); - + angular.forEach($scope.records, function (r) { + if (typeof r.acct_enabled === 'undefined') { + r.acct_enabled = true; + } + }); $scope.notificationsBox = false; $scope.recordsFetched = false; @@ -487,6 +490,8 @@ app.controller('listFTPAccounts', function ($scope, $http) { $scope.ftpLoading = false; // Hide loading when done $scope.ftpAccounts = false; $scope.changePasswordBox = true; + $scope.quotaManagementBox = true; + $scope.directoryManagementBox = true; $scope.domainFeteched = $scope.selectedDomain; @@ -499,6 +504,8 @@ app.controller('listFTPAccounts', function ($scope, $http) { $scope.ftpLoading = false; // Hide loading on error $scope.ftpAccounts = true; $scope.changePasswordBox = true; + $scope.quotaManagementBox = true; + $scope.directoryManagementBox = true; $scope.errorMessage = response.data.error_message; } @@ -514,6 +521,8 @@ app.controller('listFTPAccounts', function ($scope, $http) { $scope.ftpLoading = false; // Hide loading on connection error $scope.ftpAccounts = true; $scope.changePasswordBox = true; + $scope.quotaManagementBox = true; + $scope.directoryManagementBox = true; } @@ -540,6 +549,8 @@ app.controller('listFTPAccounts', function ($scope, $http) { $scope.canNotChangePassword = true; $scope.couldNotConnect = true; $scope.ftpLoading = false; + $scope.changePasswordBox = true; + $scope.directoryManagementBox = true; $scope.quotaManagementBox = false; $scope.notificationsBox = true; $scope.ftpUsername = record.user; @@ -628,6 +639,128 @@ app.controller('listFTPAccounts', function ($scope, $http) { } }; + $scope.manageDirectory = function (record) { + $scope.recordsFetched = true; + $scope.passwordChanged = true; + $scope.canNotChangePassword = true; + $scope.couldNotConnect = true; + $scope.ftpLoading = false; + $scope.changePasswordBox = true; + $scope.quotaManagementBox = true; + $scope.directoryManagementBox = false; + $scope.notificationsBox = true; + $scope.ftpUsername = record.user; + globalFTPUsername = record.user; + $scope.ftpPathEdit = record.dir || ''; + }; + + $scope.changeDirectoryBtn = function () { + $scope.ftpLoading = true; + var url = "/ftp/changeFTPDirectory"; + var pathVal = $scope.ftpPathEdit; + if (typeof pathVal === 'undefined' || pathVal === null) { + pathVal = ''; + } else { + pathVal = String(pathVal).trim(); + } + var data = { + ftpUserName: globalFTPUsername, + selectedDomain: $scope.selectedDomain, + path: pathVal + }; + var config = { + headers: { + 'X-CSRFToken': getCookie('csrftoken') + } + }; + $http.post(url, data, config).then(ListInitialDatas, cantLoadInitialDatas); + + function ListInitialDatas(response) { + if (response.data.changeDirectoryStatus === 1) { + $scope.ftpLoading = false; + $scope.directoryManagementBox = true; + if (typeof PNotify !== 'undefined') { + new PNotify({ + title: 'Success!', + text: 'FTP directory updated successfully.', + type: 'success' + }); + } + populateCurrentRecords(); + } else { + $scope.ftpLoading = false; + $scope.errorMessage = response.data.error_message; + if (typeof PNotify !== 'undefined') { + new PNotify({ + title: 'Error!', + text: response.data.error_message, + type: 'error' + }); + } + } + } + + function cantLoadInitialDatas() { + $scope.ftpLoading = false; + $scope.couldNotConnect = false; + if (typeof PNotify !== 'undefined') { + new PNotify({ + title: 'Error!', + text: 'Could not connect to server.', + type: 'error' + }); + } + } + }; + + $scope.setFtpAccountStatus = function (record, enabled) { + $scope.ftpLoading = true; + var url = "/ftp/setFTPAccountStatus"; + var data = { + ftpUserName: record.user, + selectedDomain: $scope.selectedDomain, + enabled: !!enabled + }; + var config = { + headers: { + 'X-CSRFToken': getCookie('csrftoken') + } + }; + $http.post(url, data, config).then(function (response) { + $scope.ftpLoading = false; + if (response.data.setFTPStatusResult === 1) { + record.acct_enabled = !!enabled; + if (typeof PNotify !== 'undefined') { + new PNotify({ + title: 'Success!', + text: enabled ? 'FTP account enabled.' : 'FTP account disabled.', + type: 'success' + }); + } + populateCurrentRecords(); + } else { + $scope.errorMessage = response.data.error_message; + if (typeof PNotify !== 'undefined') { + new PNotify({ + title: 'Error!', + text: response.data.error_message, + type: 'error' + }); + } + } + }, function () { + $scope.ftpLoading = false; + $scope.couldNotConnect = false; + if (typeof PNotify !== 'undefined') { + new PNotify({ + title: 'Error!', + text: 'Could not connect to server.', + type: 'error' + }); + } + }); + }; + }); diff --git a/ftp/templates/ftp/createFTPAccount.html b/ftp/templates/ftp/createFTPAccount.html index 6fc8e8a1c..0850528c7 100644 --- a/ftp/templates/ftp/createFTPAccount.html +++ b/ftp/templates/ftp/createFTPAccount.html @@ -415,9 +415,14 @@ .package-quota-info { margin-top: 1rem; } + + /* Hide Angular root until first digest (avoids inverted ng-hide flashing all alerts) */ + [ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak { + display: none !important; + } -
| {% trans "ID" %} | -{% trans "User Name" %} | -{% trans "Directory" %} | -{% trans "Quota" %} | -{% trans "Actions" %} | +{% trans "ID" %} | +{% trans "User Name" %} | +{% trans "Status" %} | +{% trans "Directory" %} | +{% trans "Quota" %} | +{% trans "Actions" %} |
|---|---|---|---|---|---|---|---|---|---|---|
| + | + + {% trans "Enabled" %} + + + {% trans "Disabled" %} + + | @@ -579,6 +616,18 @@ {% trans "Quota" %} + + + |