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 "Leave empty to use the website's home directory, or specify a subdirectory" %} + {% trans "Leave empty for the website's home directory. Use a subdirectory, or the full path under that home." %}
- {% trans "Examples:" %} {% trans "docs, public_html, uploads, api" %} + {% trans "Examples:" %} {% trans "public_html, uploads, or /home/yourdomain.com/public_html" %}
- {% trans "Security: Path will be restricted to this subdirectory only" %} + {% trans "Security: FTP will be restricted to this directory only (must stay inside the site home)" %}
- - {% trans "Do not use: .. ~ / or special characters like ; | & $ ` ' \" < > * ?" %} + {% trans "Do not use: .. ~ or special characters like ; | & $ ` ' \" < > * ?" %}
@@ -631,19 +636,19 @@
- +
-
+
{% trans "Cannot create FTP account. Error message:" %} {$ errorMessage $}
-
+
{% trans "FTP account successfully created with username:" %} {$ createdFTPUsername $}
-
+
{% trans "Could not connect to server. Please refresh this page." %}
diff --git a/ftp/templates/ftp/listFTPAccounts.html b/ftp/templates/ftp/listFTPAccounts.html index 97a7b25b2..6f7dffaae 100644 --- a/ftp/templates/ftp/listFTPAccounts.html +++ b/ftp/templates/ftp/listFTPAccounts.html @@ -301,6 +301,10 @@ font-size: 0.875rem; font-weight: 500; } + + .ftp-row-disabled td { + opacity: 0.75; + } @keyframes spin { to { transform: rotate(360deg); } @@ -541,25 +545,58 @@
+ +
+

{% trans "FTP home directory for" %} {$ ftpUsername $}

+
+
+
+ +
+ + {% trans "Leave empty to use this site's home directory. You may enter a subdirectory or the full path under that home (no path traversal)." %} +
+ +
+
+ +
+
+
+
+
- - - - - + + + + + + - + + diff --git a/ftp/urls.py b/ftp/urls.py index 19adb1764..e4cdebfb0 100644 --- a/ftp/urls.py +++ b/ftp/urls.py @@ -15,6 +15,8 @@ urlpatterns = [ path('listFTPAccounts', views.listFTPAccounts, name='listFTPAccounts'), path('getAllFTPAccounts', views.getAllFTPAccounts, name='getAllFTPAccounts'), path('changePassword', views.changePassword, name='changePassword'), + path('changeFTPDirectory', views.changeFTPDirectory, name='changeFTPDirectory'), + path('setFTPAccountStatus', views.setFTPAccountStatus, name='setFTPAccountStatus'), path('updateFTPQuota', views.updateFTPQuota, name='updateFTPQuota'), path('getFTPQuotaUsage', views.getFTPQuotaUsage, name='getFTPQuotaUsage'), path('migrateFTPQuotas', views.migrateFTPQuotas, name='migrateFTPQuotas'), diff --git a/ftp/views.py b/ftp/views.py index f7f2b088b..f42fc6468 100644 --- a/ftp/views.py +++ b/ftp/views.py @@ -255,6 +255,42 @@ def changePassword(request): except KeyError: return redirect(loadLoginPage) +def changeFTPDirectory(request): + try: + result = pluginManager.preChangeFTPDirectory(request) + if result != 200: + return result + + fm = FTPManager(request) + coreResult = fm.changeFTPDirectory() + + result = pluginManager.postChangeFTPDirectory(request, coreResult) + if result != 200: + return result + + return coreResult + + except KeyError: + return redirect(loadLoginPage) + +def setFTPAccountStatus(request): + try: + result = pluginManager.preSetFTPAccountStatus(request) + if result != 200: + return result + + fm = FTPManager(request) + coreResult = fm.setFTPAccountStatus() + + result = pluginManager.postSetFTPAccountStatus(request, coreResult) + if result != 200: + return result + + return coreResult + + except KeyError: + return redirect(loadLoginPage) + def updateFTPQuota(request): try: fm = FTPManager(request) diff --git a/install/pure-ftpd-one/pureftpd-mysql.conf b/install/pure-ftpd-one/pureftpd-mysql.conf index f882c9600..befb00bba 100644 --- a/install/pure-ftpd-one/pureftpd-mysql.conf +++ b/install/pure-ftpd-one/pureftpd-mysql.conf @@ -3,12 +3,12 @@ MYSQLPort 3306 MYSQLSocket /var/lib/mysql/mysql.sock MYSQLDatabase cyberpanel MYSQLCrypt md5 -MYSQLGetDir SELECT Dir FROM users WHERE User='\L' -MYSQLGetGID SELECT Gid FROM users WHERE User='\L' -MYSQLGetPW SELECT Password FROM users WHERE User='\L' -MYSQLGetUID SELECT Uid FROM users WHERE User='\L' -# Quota enforcement queries -MYSQLGetQTAFS SELECT QuotaSize FROM users WHERE User='\L' -MYSQLGetQTAUS SELECT 0 FROM users WHERE User='\L' +# Only accounts with Status='1' can authenticate (disabled = Status '0') +MYSQLGetDir SELECT Dir FROM users WHERE User='\L' AND Status='1' +MYSQLGetGID SELECT Gid FROM users WHERE User='\L' AND Status='1' +MYSQLGetPW SELECT Password FROM users WHERE User='\L' AND Status='1' +MYSQLGetUID SELECT Uid FROM users WHERE User='\L' AND Status='1' +MYSQLGetQTAFS SELECT QuotaSize FROM users WHERE User='\L' AND Status='1' +MYSQLGetQTAUS SELECT 0 FROM users WHERE User='\L' AND Status='1' MYSQLPassword 1qaz@9xvps MYSQLUser cyberpanel diff --git a/install/pure-ftpd/pureftpd-mysql.conf b/install/pure-ftpd/pureftpd-mysql.conf index f2e5548f0..2dd1ad2f8 100644 --- a/install/pure-ftpd/pureftpd-mysql.conf +++ b/install/pure-ftpd/pureftpd-mysql.conf @@ -3,12 +3,13 @@ MYSQLPort 3307 MYSQLSocket /var/lib/mysql1/mysql.sock MYSQLDatabase cyberpanel MYSQLCrypt md5 -MYSQLGetDir SELECT Dir FROM users WHERE User='\L' -MYSQLGetGID SELECT Gid FROM users WHERE User='\L' -MYSQLGetPW SELECT Password FROM users WHERE User='\L' -MYSQLGetUID SELECT Uid FROM users WHERE User='\L' +# Only accounts with Status='1' can authenticate (disabled = Status '0') +MYSQLGetDir SELECT Dir FROM users WHERE User='\L' AND Status='1' +MYSQLGetGID SELECT Gid FROM users WHERE User='\L' AND Status='1' +MYSQLGetPW SELECT Password FROM users WHERE User='\L' AND Status='1' +MYSQLGetUID SELECT Uid FROM users WHERE User='\L' AND Status='1' # Quota enforcement queries -MYSQLGetQTAFS SELECT QuotaSize FROM users WHERE User='\L' -MYSQLGetQTAUS SELECT 0 FROM users WHERE User='\L' +MYSQLGetQTAFS SELECT QuotaSize FROM users WHERE User='\L' AND Status='1' +MYSQLGetQTAUS SELECT 0 FROM users WHERE User='\L' AND Status='1' MYSQLPassword 1qaz@9xvps MYSQLUser cyberpanel diff --git a/plogical/ftpUtilities.py b/plogical/ftpUtilities.py index d2be496a5..e4f3693fa 100644 --- a/plogical/ftpUtilities.py +++ b/plogical/ftpUtilities.py @@ -22,6 +22,57 @@ from plogical.processUtilities import ProcessUtilities class FTPUtilities: + @staticmethod + def get_domain_home_directory(domain_name): + """ + Filesystem root for the selected site: primary domain /home/, + or child domain /home// per CyberPanel vhost layout. + """ + try: + child = ChildDomains.objects.select_related('master').get(domain=domain_name) + master_dom = child.master.domain + rel = (child.path or '').strip().strip('/') + if rel: + return os.path.abspath('/home/%s/%s' % (master_dom, rel)) + except ChildDomains.DoesNotExist: + pass + return os.path.abspath('/home/' + domain_name) + + @staticmethod + def assert_ftp_raw_path_safe(raw): + """Reject shell metacharacters and obvious traversal markers in user input.""" + if raw is None or not str(raw).strip(): + return + s = str(raw) + dangerous_chars = [';', '|', '&', '$', '`', '\'', '"', '<', '>', '*', '?'] + if any(char in s for char in dangerous_chars): + raise BaseException("Invalid path: Path contains dangerous characters") + if '..' in s or '~' in s: + raise BaseException("Invalid path: Path cannot contain '..' or '~'") + + @staticmethod + def resolve_ftp_home_path(domain_name, raw_path): + """ + Resolve FTP home directory under domain_name. + Empty / None / 'None' -> domain document root only. + Absolute paths are allowed if they resolve under that root (no /home duplication). + """ + domain_home = FTPUtilities.get_domain_home_directory(domain_name) + if raw_path is None: + return domain_home + raw = str(raw_path).strip() + if raw == '' or raw == 'None': + return domain_home + FTPUtilities.assert_ftp_raw_path_safe(raw) + if raw.startswith('/'): + candidate = os.path.abspath(raw) + else: + candidate = os.path.abspath(os.path.join(domain_home, raw)) + dh = domain_home + if candidate != dh and not candidate.startswith(dh + os.sep): + raise BaseException("Security violation: Path must be within domain home directory") + return candidate + @staticmethod def createNewFTPAccount(udb,upass,username,password,path): try: @@ -143,37 +194,14 @@ class FTPUtilities: ## gid , uid ends - # Enhanced path validation and handling - if path and path.strip() and path != 'None': - # Clean the path - path = path.strip().lstrip("/") - - # Additional security checks - if path.find("..") > -1 or path.find("~") > -1 or path.startswith("/"): - raise BaseException("Invalid path: Path must be relative and not contain '..' or '~' or start with '/'") - - # Check for dangerous characters - dangerous_chars = [';', '|', '&', '$', '`', '\'', '"', '<', '>', '*', '?'] - if any(char in path for char in dangerous_chars): - raise BaseException("Invalid path: Path contains dangerous characters") - - # Construct full path - full_path = "/home/" + domainName + "/" + path - - # Additional security: ensure path is within domain directory - domain_home = "/home/" + domainName - if not os.path.abspath(full_path).startswith(os.path.abspath(domain_home)): - raise BaseException("Security violation: Path must be within domain directory") - - result = FTPUtilities.ftpFunctions(full_path, externalApp) - - if result[0] == 1: - path = full_path - else: + # Path: empty -> domain home; relative or absolute under domain home (no duplicate /home/... prefix) + if path and str(path).strip() and str(path).strip() != 'None': + path = FTPUtilities.resolve_ftp_home_path(domainName, path) + result = FTPUtilities.ftpFunctions(path, externalApp) + if result[0] != 1: raise BaseException("Path validation failed: " + result[1]) - else: - path = "/home/" + domainName + path = FTPUtilities.get_domain_home_directory(domainName) # Enhanced symlink handling if os.path.islink(path): @@ -251,6 +279,63 @@ class FTPUtilities: except BaseException as msg: return 0, str(msg) + @staticmethod + def changeFTPDirectory(userName, raw_path, selected_domain): + """ + Update FTP user home directory after creation. selected_domain must match + the master website domain for this account (same as list FTP dropdown). + """ + try: + website = Websites.objects.get(domain=selected_domain) + ftp = Users.objects.get(user=userName) + if ftp.domain_id != website.id: + raise BaseException("FTP user does not belong to the selected domain") + + externalApp = website.externalApp + resolved = FTPUtilities.resolve_ftp_home_path(selected_domain, raw_path) + + if os.path.islink(resolved): + logging.CyberCPLogFileWriter.writeToFile( + "FTP path is symlinked: %s" % resolved) + raise BaseException("Cannot set FTP directory: Path is a symbolic link") + + result = FTPUtilities.ftpFunctions(resolved, externalApp) + if result[0] != 1: + raise BaseException("Path validation failed: " + result[1]) + + ftp.dir = resolved + ftp.save() + return 1, None + except Users.DoesNotExist: + return 0, "FTP user not found" + except Websites.DoesNotExist: + return 0, "Domain not found" + except BaseException as msg: + logging.CyberCPLogFileWriter.writeToFile(str(msg) + " [changeFTPDirectory]") + return 0, str(msg) + + @staticmethod + def setFTPAccountStatus(userName, enabled, selected_domain): + """ + Enable or disable FTP login (Status '1' / '0'). Pure-FTPd must use + MySQL queries that include AND Status='1' for authentication. + """ + try: + website = Websites.objects.get(domain=selected_domain) + ftp = Users.objects.get(user=userName) + if ftp.domain_id != website.id: + raise BaseException("FTP user does not belong to the selected domain") + ftp.status = '1' if enabled else '0' + ftp.save() + return 1, None + except Users.DoesNotExist: + return 0, "FTP user not found" + except Websites.DoesNotExist: + return 0, "Domain not found" + except BaseException as msg: + logging.CyberCPLogFileWriter.writeToFile(str(msg) + " [setFTPAccountStatus]") + return 0, str(msg) + @staticmethod def changeFTPPassword(userName, password): try: diff --git a/public/static/ftp/ftp.js b/public/static/ftp/ftp.js index 875bf72cd..1071d554a 100644 --- a/public/static/ftp/ftp.js +++ b/public/static/ftp/ftp.js @@ -6,14 +6,25 @@ /* Java script code to create account */ app.controller('createFTPAccount', function ($scope, $http) { - - $scope.ftpLoading = false; $scope.ftpDetails = 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(); + + // Only use select2 if it's actually a function (avoids errors when Rocket Loader defers scripts) if (typeof $ !== 'undefined' && $ && typeof $.fn !== 'undefined' && typeof $.fn.select2 === 'function') { try { var $sel = $('.create-ftp-acct-select'); @@ -21,22 +32,33 @@ app.controller('createFTPAccount', function ($scope, $http) { $sel.select2(); $sel.on('select2:select', function (e) { var data = e.params.data; - $scope.ftpDomain = data.text; - $scope.ftpDetails = false; - $scope.$apply(); + $scope.$evalAsync(function () { + $scope.ftpDomain = data.text; + $scope.ftpDetails = false; + }); $(".ftpDetails, .account-details").show(); }); + } else { + initNativeSelect(); } - } catch (err) {} + } catch (err) { + initNativeSelect(); + } + } else { + initNativeSelect(); + } + function initNativeSelect() { + $('.create-ftp-acct-select').off('select2:select').on('change', function () { + var val = $(this).val(); + $scope.$evalAsync(function () { + $scope.ftpDomain = val; + $scope.ftpDetails = (val && val !== '') ? false : true; + }); + $(".ftpDetails, .account-details").show(); + }); } - $('.create-ftp-acct-select').off('select2:select').on('change', function () { - $scope.ftpDomain = $(this).val(); - $scope.ftpDetails = ($scope.ftpDomain && $scope.ftpDomain !== '') ? false : true; - $scope.$apply(); - $(".ftpDetails, .account-details").show(); - }); }); - + $scope.showFTPDetails = function() { if ($scope.ftpDomain && $scope.ftpDomain !== "") { $scope.ftpDetails = false; @@ -49,19 +71,45 @@ app.controller('createFTPAccount', function ($scope, $http) { $scope.createFTPAccount = function () { - $scope.ftpLoading = false; + var submissionCompleted = false; + + $scope.ftpLoading = true; // Show loading while creating $scope.ftpDetails = false; - $scope.canNotCreate = true; - $scope.successfullyCreated = true; - $scope.couldNotConnect = true; + resetFtpCreateAlerts(); var ftpDomain = $scope.ftpDomain; var ftpUserName = $scope.ftpUserName; var ftpPassword = $scope.ftpPassword; var path = $scope.ftpPath; - if (typeof path === 'undefined') { + // Enhanced path validation + if (typeof path === 'undefined' || path === null) { path = ""; + } else { + path = path.trim(); + } + + // Client-side path validation + if (path && path !== "") { + // Check for dangerous characters + var dangerousChars = /[;&|$`'"<>*?~]/; + if (dangerousChars.test(path)) { + $scope.ftpLoading = false; + resetFtpCreateAlerts(); + $scope.alertFtpCreateError = true; + $scope.errorMessage = "Invalid path: Path contains dangerous characters"; + return; + } + + // Check for path traversal attempts + if (path.indexOf("..") !== -1 || path.indexOf("~") !== -1) { + $scope.ftpLoading = false; + resetFtpCreateAlerts(); + $scope.alertFtpCreateError = true; + $scope.errorMessage = "Invalid path: Path cannot contain '..' or '~'"; + return; + } + // Absolute paths under /home/... are allowed; server validates they stay inside the site home } var url = "/ftp/submitFTPCreation"; @@ -71,7 +119,10 @@ app.controller('createFTPAccount', function ($scope, $http) { ftpDomain: ftpDomain, ftpUserName: ftpUserName, passwordByPass: ftpPassword, - path: path, + path: path || '', + api: '0', + enableCustomQuota: $scope.enableCustomQuota || false, + customQuotaSize: $scope.customQuotaSize || 0, }; var config = { @@ -84,60 +135,65 @@ app.controller('createFTPAccount', function ($scope, $http) { function ListInitialDatas(response) { - - - if (response.data.creatFTPStatus === 1) { - $scope.ftpLoading = true; - new PNotify({ - title: 'Success!', - text: 'FTP account successfully created.', - type: 'success' - }); - - - } else { - $scope.ftpLoading = true; - new PNotify({ - title: 'Operation Failed!', - text: response.data.error_message, - type: 'error' - }); - - + if (submissionCompleted) { + return; + } + submissionCompleted = true; + $scope.ftpLoading = false; + resetFtpCreateAlerts(); + if (response.data && response.data.creatFTPStatus === 1) { + $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.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' }); + } } - } + function cantLoadInitialDatas(response) { - - $scope.ftpLoading = true; - new PNotify({ - title: 'Operation Failed!', - text: 'Could not connect to server, please refresh this page', - type: 'error' - }); - - + if (submissionCompleted) { + return; + } + submissionCompleted = true; + $scope.ftpLoading = false; + 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' }); + } } }; $scope.hideFewDetails = function () { - - $scope.successfullyCreated = true; - - + resetFtpCreateAlerts(); }; /// $scope.generatePassword = function () { - $( ".ftpPasswordView" ).show(); + $(".ftpPasswordView").show(); + $scope.generatedPasswordView = false; $scope.ftpPassword = randomPassword(16); }; $scope.usePassword = function () { - $(".ftpPasswordView" ).hide(); + $(".ftpPasswordView").hide(); + $scope.generatedPasswordView = true; + }; + + // Quota management functions + $scope.toggleCustomQuota = function() { + if (!$scope.enableCustomQuota) { + $scope.customQuotaSize = 0; + } }; }); @@ -199,32 +255,24 @@ app.controller('deleteFTPAccount', function ($scope, $http) { } else { - $scope.ftpAccountsOfDomain = true; $scope.deleteFTPButton = true; - $scope.deleteFailure = true; + $scope.deleteFailure = false; $scope.deleteSuccess = true; - $scope.couldNotConnect = false; + $scope.couldNotConnect = true; $scope.deleteFTPButtonInit = true; - + $scope.errorMessage = (response.data && (response.data.error_message || response.data.errorMessage)) || 'Unknown error'; } - - } function cantLoadInitialDatas(response) { - $scope.ftpAccountsOfDomain = true; $scope.deleteFTPButton = true; $scope.deleteFailure = true; $scope.deleteSuccess = true; $scope.couldNotConnect = false; $scope.deleteFTPButtonInit = true; - - } - - }; $scope.deleteFTPAccount = function () { @@ -315,9 +363,11 @@ app.controller('listFTPAccounts', function ($scope, $http) { $scope.passwordChanged = true; $scope.canNotChangePassword = true; $scope.couldNotConnect = true; - $scope.ftpLoading = true; + $scope.ftpLoading = false; $scope.ftpAccounts = true; $scope.changePasswordBox = true; + $scope.quotaManagementBox = true; + $scope.directoryManagementBox = true; $scope.notificationsBox = true; var globalFTPUsername = ""; @@ -331,8 +381,10 @@ app.controller('listFTPAccounts', function ($scope, $http) { $scope.passwordChanged = true; $scope.canNotChangePassword = true; $scope.couldNotConnect = true; - $scope.ftpLoading = 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; @@ -341,7 +393,7 @@ app.controller('listFTPAccounts', function ($scope, $http) { $scope.changePasswordBtn = function () { - $scope.ftpLoading = false; + $scope.ftpLoading = true; // Show loading while changing password url = "/ftp/changePassword"; @@ -367,13 +419,13 @@ app.controller('listFTPAccounts', function ($scope, $http) { if (response.data.changePasswordStatus == 1) { $scope.notificationsBox = false; $scope.passwordChanged = false; - $scope.ftpLoading = true; + $scope.ftpLoading = false; // Hide loading when done $scope.domainFeteched = $scope.selectedDomain; } else { $scope.notificationsBox = false; $scope.canNotChangePassword = false; - $scope.ftpLoading = true; + $scope.ftpLoading = false; // Hide loading on error $scope.canNotChangePassword = false; $scope.errorMessage = response.data.error_message; } @@ -383,7 +435,7 @@ app.controller('listFTPAccounts', function ($scope, $http) { function cantLoadInitialDatas(response) { $scope.notificationsBox = false; $scope.couldNotConnect = false; - $scope.ftpLoading = true; + $scope.ftpLoading = false; // Hide loading on connection error } @@ -394,9 +446,11 @@ app.controller('listFTPAccounts', function ($scope, $http) { $scope.passwordChanged = true; $scope.canNotChangePassword = true; $scope.couldNotConnect = true; - $scope.ftpLoading = false; + $scope.ftpLoading = true; // Show loading while fetching $scope.ftpAccounts = true; $scope.changePasswordBox = true; + $scope.quotaManagementBox = true; + $scope.directoryManagementBox = true; var selectedDomain = $scope.selectedDomain; @@ -422,16 +476,22 @@ 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; $scope.passwordChanged = true; $scope.canNotChangePassword = true; $scope.couldNotConnect = true; - $scope.ftpLoading = true; + $scope.ftpLoading = false; // Hide loading when done $scope.ftpAccounts = false; $scope.changePasswordBox = true; + $scope.quotaManagementBox = true; + $scope.directoryManagementBox = true; $scope.domainFeteched = $scope.selectedDomain; @@ -441,9 +501,11 @@ app.controller('listFTPAccounts', function ($scope, $http) { $scope.passwordChanged = true; $scope.canNotChangePassword = true; $scope.couldNotConnect = true; - $scope.ftpLoading = true; + $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; } @@ -456,9 +518,11 @@ app.controller('listFTPAccounts', function ($scope, $http) { $scope.passwordChanged = true; $scope.canNotChangePassword = true; $scope.couldNotConnect = false; - $scope.ftpLoading = true; + $scope.ftpLoading = false; // Hide loading on connection error $scope.ftpAccounts = true; $scope.changePasswordBox = true; + $scope.quotaManagementBox = true; + $scope.directoryManagementBox = true; } @@ -478,4 +542,292 @@ app.controller('listFTPAccounts', function ($scope, $http) { $scope.generatedPasswordView = true; }; + // Quota management functions + $scope.manageQuota = function (record) { + $scope.recordsFetched = true; + $scope.passwordChanged = true; + $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; + globalFTPUsername = record.user; + + // Set current quota info + $scope.currentQuotaInfo = record.quotasize; + $scope.packageQuota = record.package_quota; + $scope.enableCustomQuotaEdit = record.custom_quota_enabled; + $scope.customQuotaSizeEdit = record.custom_quota_size || 0; + }; + + $scope.toggleCustomQuotaEdit = function() { + if (!$scope.enableCustomQuotaEdit) { + $scope.customQuotaSizeEdit = 0; + } + }; + + $scope.updateQuotaBtn = function () { + $scope.ftpLoading = true; + + url = "/ftp/updateFTPQuota"; + + var data = { + ftpUserName: globalFTPUsername, + customQuotaSize: parseInt($scope.customQuotaSizeEdit) || 0, + enableCustomQuota: $scope.enableCustomQuotaEdit || false, + }; + + var config = { + headers: { + 'X-CSRFToken': getCookie('csrftoken') + } + }; + + $http.post(url, data, config).then(ListInitialDatas, cantLoadInitialDatas); + + function ListInitialDatas(response) { + if (response.data.updateQuotaStatus == 1) { + $scope.notificationsBox = false; + $scope.quotaUpdated = false; + $scope.ftpLoading = false; + $scope.domainFeteched = $scope.selectedDomain; + + // Refresh the records to show updated quota + populateCurrentRecords(); + + // Show success notification + if (typeof PNotify !== 'undefined') { + new PNotify({ + title: 'Success!', + text: 'FTP quota updated successfully.', + type: 'success' + }); + } + } else { + $scope.notificationsBox = false; + $scope.quotaUpdateFailed = false; + $scope.ftpLoading = false; + $scope.errorMessage = response.data.error_message; + + // Show error notification + if (typeof PNotify !== 'undefined') { + new PNotify({ + title: 'Error!', + text: response.data.error_message, + type: 'error' + }); + } + } + } + + function cantLoadInitialDatas(response) { + $scope.notificationsBox = false; + $scope.couldNotConnect = false; + $scope.ftpLoading = false; + + // Show error notification + if (typeof PNotify !== 'undefined') { + new PNotify({ + title: 'Error!', + text: 'Could not connect to server.', + type: 'error' + }); + } + } + }; + + $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' + }); + } + } + }; + }); + + + +app.controller('Resetftpconf', function ($scope, $http, $timeout, $window){ + $scope.Loading = true; + $scope.NotifyBox = true; + $scope.InstallBox = true; + $scope.installationDetailsForm = false; + $scope.alertType = ''; + $scope.errorMessage = ''; + + $scope.resetftp = function () { + $scope.Loading = false; + $scope.installationDetailsForm = true; + $scope.InstallBox = false; + $scope.alertType = ''; + $scope.NotifyBox = true; + + var url = "/ftp/resetftpnow"; + var data = {}; + var config = { + headers: { + 'X-CSRFToken': getCookie('csrftoken'), + 'Content-Type': 'application/json' + } + }; + + $http.post(url, data, config).then(ListInitialData, cantLoadInitialData); + + function ListInitialData(response) { + if (response.data && response.data.status === 1) { + $scope.NotifyBox = true; + $scope.InstallBox = false; + $scope.Loading = false; + $scope.alertType = ''; + $scope.statusfile = response.data.tempStatusPath; + $timeout(getRequestStatus, 1000); + } else { + $scope.errorMessage = (response.data && (response.data.error_message || response.data.errorMessage)) || 'Unknown error'; + $scope.alertType = 'failedToStart'; + $scope.NotifyBox = false; + $scope.InstallBox = true; + $scope.Loading = false; + } + } + + function cantLoadInitialData(response) { + $scope.errorMessage = (response && response.data && (response.data.error_message || response.data.errorMessage)) || 'Could not connect to server. Please refresh this page.'; + $scope.alertType = 'couldNotConnect'; + $scope.NotifyBox = false; + $scope.InstallBox = true; + $scope.Loading = false; + try { + new PNotify({ title: 'Error!', text: $scope.errorMessage, type: 'error' }); + } catch (e) {} + } + } + + + + var statusPollPromise = null; + function getRequestStatus() { + $scope.NotifyBox = true; + $scope.InstallBox = false; + $scope.Loading = false; + $scope.alertType = ''; + + var url = "/ftp/getresetstatus"; + var data = { statusfile: $scope.statusfile }; + var config = { + headers: { + 'X-CSRFToken': getCookie('csrftoken'), + 'Content-Type': 'application/json' + } + }; + + $http.post(url, data, config).then(ListInitialDatas, cantLoadInitialDatas); + + function ListInitialDatas(response) { + if (!response.data) return; + if (response.data.abort === 0) { + $scope.alertType = ''; + $scope.requestData = response.data.requestStatus || ''; + statusPollPromise = $timeout(getRequestStatus, 1000); + } else { + if (statusPollPromise) { + $timeout.cancel(statusPollPromise); + statusPollPromise = null; + } + $scope.NotifyBox = false; + $scope.InstallBox = false; + $scope.Loading = false; + $scope.requestData = response.data.requestStatus || ''; + + if (response.data.installed === 0) { + $scope.alertType = 'resetFailed'; + $scope.errorMessage = response.data.error_message || 'Reset failed'; + } else { + $scope.alertType = 'success'; + $timeout(function () { $window.location.reload(); }, 3000); + } + } + } + + function cantLoadInitialDatas(response) { + if (statusPollPromise) { + $timeout.cancel(statusPollPromise); + statusPollPromise = null; + } + $scope.alertType = 'couldNotConnect'; + $scope.errorMessage = (response && response.data && (response.data.error_message || response.data.errorMessage)) || 'Could not connect to server. Please refresh this page.'; + $scope.NotifyBox = false; + $scope.InstallBox = true; + $scope.Loading = false; + } + } +}); \ No newline at end of file diff --git a/static/ftp/ftp.js b/static/ftp/ftp.js index ef6cd4a4e..1071d554a 100644 --- a/static/ftp/ftp.js +++ b/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,80 @@ 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' + }); + } + } + }; + }); diff --git a/upgrade_modules/10_post_tweak.sh b/upgrade_modules/10_post_tweak.sh index 3b9a38c94..85cf57c29 100644 --- a/upgrade_modules/10_post_tweak.sh +++ b/upgrade_modules/10_post_tweak.sh @@ -368,6 +368,18 @@ if [ -f /usr/local/CyberCP/public/phpmyadmin/phpmyadminsignin.php ]; then grep -q "127.0.0.1" /usr/local/CyberCP/public/phpmyadmin/phpmyadminsignin.php && echo -e "[$(date +"%Y-%m-%d %H:%M:%S")] phpMyAdmin signon default host set to 127.0.0.1" | tee -a /var/log/cyberpanel_upgrade_debug.log fi +# FTP users table: custom quota columns (fixes 1054 on Create FTP Account if schema predates model fields) +if [[ -f /usr/local/CyberCP/CPScripts/ensure_ftp_users_quota_columns.py ]]; then + if [[ -x /usr/local/CyberCP/bin/python ]]; then + CP_DIR=/usr/local/CyberCP /usr/local/CyberCP/bin/python /usr/local/CyberCP/CPScripts/ensure_ftp_users_quota_columns.py /usr/local/CyberCP 2>&1 | tee -a /var/log/cyberpanel_upgrade_debug.log || true + else + CP_DIR=/usr/local/CyberCP python3 /usr/local/CyberCP/CPScripts/ensure_ftp_users_quota_columns.py /usr/local/CyberCP 2>&1 | tee -a /var/log/cyberpanel_upgrade_debug.log || true + fi + echo -e "[$(date +"%Y-%m-%d %H:%M:%S")] Ran ensure_ftp_users_quota_columns (FTP users table custom quota columns)" | tee -a /var/log/cyberpanel_upgrade_debug.log +else + echo -e "[$(date +"%Y-%m-%d %H:%M:%S")] INFO: ensure_ftp_users_quota_columns.py not in CyberCP yet; run deploy-ftp-users-custom-quota-columns.sh after sync" | tee -a /var/log/cyberpanel_upgrade_debug.log +fi + systemctl restart lscpd }
{% 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" %} + + +