FTP: path normalization, post-create directory edit, enable/disable

- Resolve FTP home paths without duplicating /home/domain; support absolute paths under site home
- Add changeFTPDirectory API and list UI; improve create form path help
- Add setFTPAccountStatus (Status 0/1) with Enable/Disable on list page
- Pure-FTPd MySQL: require Status='1' for authentication in install templates
- Plugin signals for change directory and account status
This commit is contained in:
master3395
2026-03-24 20:22:56 +01:00
parent 836db0f378
commit 90fcc7b621
13 changed files with 649 additions and 86 deletions

View File

@@ -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']

View File

@@ -33,4 +33,20 @@ class pluginManager:
@staticmethod
def postChangePassword(request, response):
return pluginManagerGlobal.globalPlug(request, postChangePassword, response)
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)

View File

@@ -26,4 +26,11 @@ postSubmitFTPDelete = Signal()
preChangePassword = Signal()
## This event is fired after CyberPanel core finished deletion of child-domain
postChangePassword = Signal()
postChangePassword = Signal()
## Before / after changing FTP account home directory (list FTP page)
preChangeFTPDirectory = Signal()
postChangeFTPDirectory = Signal()
preSetFTPAccountStatus = Signal()
postSetFTPAccountStatus = Signal()

View File

@@ -109,15 +109,7 @@ app.controller('createFTPAccount', function ($scope, $http) {
$scope.errorMessage = "Invalid path: Path cannot contain '..' or '~'";
return;
}
// Check if path starts with slash (should be relative)
if (path.startsWith("/")) {
$scope.ftpLoading = false;
resetFtpCreateAlerts();
$scope.alertFtpCreateError = 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";
@@ -375,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 = "";
@@ -390,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;
@@ -454,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;
@@ -479,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;
@@ -489,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;
@@ -501,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;
}
@@ -516,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;
}
@@ -542,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;
@@ -630,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'
});
}
});
};
});

View File

@@ -569,26 +569,26 @@
</div>
<div ng-hide="ftpDetails" class="form-group">
<label class="form-label">{% trans "Path (Relative)" %}</label>
<label class="form-label">{% trans "FTP home path" %}</label>
<div class="path-info">
<i class="fas fa-folder"></i>
{% 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." %}
<br>
<small style="margin-top: 0.5rem; display: block; color: var(--text-secondary, #64748b);">
<i class="fas fa-info-circle"></i>
<strong>{% trans "Examples:" %}</strong> {% trans "docs, public_html, uploads, api" %}
<strong>{% trans "Examples:" %}</strong> {% trans "public_html, uploads, or /home/yourdomain.com/public_html" %}
<br>
<i class="fas fa-shield-alt"></i>
{% 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)" %}
</small>
</div>
<input placeholder="{% trans 'e.g., docs or public_html (leave empty for home directory)' %}"
<input placeholder="{% trans 'e.g. public_html or full path under site home (optional)' %}"
type="text" class="form-control" ng-model="ftpPath"
pattern="^[a-zA-Z0-9._/-]+$"
title="{% trans 'Only letters, numbers, dots, underscores, hyphens, and forward slashes allowed' %}">
<small style="color: var(--text-secondary, #64748b); margin-top: 0.5rem; display: block;">
<i class="fas fa-exclamation-triangle"></i>
{% trans "Do not use: .. ~ / or special characters like ; | & $ ` ' \" < > * ?" %}
{% trans "Do not use: .. ~ or special characters like ; | & $ ` ' \" < > * ?" %}
</small>
</div>

View File

@@ -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 @@
</div>
</div>
<!-- Change FTP home directory -->
<div ng-hide="directoryManagementBox" class="password-section">
<h3><i class="fas fa-folder-open"></i> {% trans "FTP home directory for" %} {$ ftpUsername $}</h3>
<div class="row">
<div class="col-md-8">
<div class="form-group">
<label class="form-label">{% trans "Path" %}</label>
<div class="path-info" style="margin-bottom: 0.75rem;">
<i class="fas fa-info-circle"></i>
{% 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)." %}
</div>
<input type="text" class="form-control" ng-model="ftpPathEdit"
placeholder="{% trans 'e.g. public_html/sub or full path under site home' %}">
</div>
<div style="margin-top: 1rem;">
<button type="button" ng-click="changeDirectoryBtn()" class="btn-primary">
<i class="fas fa-save"></i>
{% trans "Update Directory" %}
</button>
</div>
</div>
</div>
</div>
<!-- FTP Accounts Table -->
<div ng-hide="ftpAccounts">
<table class="ftp-table">
<thead>
<tr>
<th style="width: 8%;">{% trans "ID" %}</th>
<th style="width: 20%;">{% trans "User Name" %}</th>
<th style="width: 30%;">{% trans "Directory" %}</th>
<th style="width: 15%;">{% trans "Quota" %}</th>
<th style="width: 27%; text-align: center;">{% trans "Actions" %}</th>
<th style="width: 7%;">{% trans "ID" %}</th>
<th style="width: 16%;">{% trans "User Name" %}</th>
<th style="width: 10%;">{% trans "Status" %}</th>
<th style="width: 24%;">{% trans "Directory" %}</th>
<th style="width: 12%;">{% trans "Quota" %}</th>
<th style="width: 31%; text-align: center;">{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="record in records track by $index">
<tr ng-repeat="record in records track by $index" ng-class="{'ftp-row-disabled': !record.acct_enabled}">
<td><strong ng-bind="record.id"></strong></td>
<td>
<i class="fas fa-user-circle" style="color: var(--accent-color, #5b5fcf); margin-right: 0.5rem;"></i>
<span ng-bind="record.user"></span>
</td>
<td>
<span class="quota-badge" ng-show="record.acct_enabled" style="background: rgba(34,197,94,0.15); color: #16a34a;">
<i class="fas fa-check-circle"></i> {% trans "Enabled" %}
</span>
<span class="quota-badge" ng-hide="record.acct_enabled" style="background: rgba(239,68,68,0.12); color: #dc2626;">
<i class="fas fa-ban"></i> {% trans "Disabled" %}
</span>
</td>
<td>
<span class="directory-badge">
<i class="fas fa-folder"></i>
@@ -579,6 +616,18 @@
<i class="fas fa-hdd"></i>
{% trans "Quota" %}
</button>
<button type="button" ng-click="manageDirectory(record)" class="btn-action" title="{% trans 'Change which directory this FTP user is locked to' %}">
<i class="fas fa-folder-open"></i>
{% trans "Directory" %}
</button>
<button type="button" ng-click="setFtpAccountStatus(record, false)" ng-show="record.acct_enabled" class="btn-action" style="opacity: 0.95;" title="{% trans 'Block FTP login without deleting the account' %}">
<i class="fas fa-user-slash"></i>
{% trans "Disable" %}
</button>
<button type="button" ng-click="setFtpAccountStatus(record, true)" ng-hide="record.acct_enabled" class="btn-action" title="{% trans 'Allow FTP login again' %}">
<i class="fas fa-user-check"></i>
{% trans "Enable" %}
</button>
</div>
</td>
</tr>

View File

@@ -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'),

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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/<domain>,
or child domain /home/<master>/<child.path> 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:

View File

@@ -109,15 +109,7 @@ app.controller('createFTPAccount', function ($scope, $http) {
$scope.errorMessage = "Invalid path: Path cannot contain '..' or '~'";
return;
}
// Check if path starts with slash (should be relative)
if (path.startsWith("/")) {
$scope.ftpLoading = false;
resetFtpCreateAlerts();
$scope.alertFtpCreateError = 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";
@@ -375,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 = "";
@@ -390,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;
@@ -454,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;
@@ -479,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;
@@ -489,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;
@@ -501,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;
}
@@ -516,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;
}
@@ -542,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;
@@ -630,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'
});
}
}
};
});

View File

@@ -109,15 +109,7 @@ app.controller('createFTPAccount', function ($scope, $http) {
$scope.errorMessage = "Invalid path: Path cannot contain '..' or '~'";
return;
}
// Check if path starts with slash (should be relative)
if (path.startsWith("/")) {
$scope.ftpLoading = false;
resetFtpCreateAlerts();
$scope.alertFtpCreateError = 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";
@@ -375,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 = "";
@@ -390,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;
@@ -454,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;
@@ -479,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;
@@ -489,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;
@@ -501,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;
}
@@ -516,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;
}
@@ -542,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;
@@ -630,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'
});
}
}
};
});