Fix quota endpoint: path detection, error handling, better messages

- Backend: detect the actual filesystem mountpoint via 'df' instead of
  hardcoding /srv/samba — setquota needs the mountpoint, not a subpath
- Backend: catch CalledProcessError and FileNotFoundError from setquota
  and return the actual error message (e.g. quota not enabled, package
  not installed) instead of crashing with 500
- Frontend: always send the share path in the quota API call so the
  backend can find the right filesystem, even for SMB per-user quotas
- Frontend: read the actual error message from the response and show it
  in the toast instead of just "N quota(s) failed"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Sonnet 4.6 committed 2026-06-19 22:36:15 +02:00
1 parent 74737568b0
commit 6631b9e832
2 files changed
+33 -12

No files matched your search

+22 -7
View File
@@ -417,14 +417,23 @@ def toggle_nfs():
# ===== QUOTA ===== # ===== QUOTA =====
def _find_mountpoint(path):
"""Return the filesystem mountpoint that contains path."""
r = subprocess.run(
['df', '--output=target', path],
capture_output=True, text=True,
)
lines = [l.strip() for l in r.stdout.strip().splitlines() if l.strip() and l.strip() != 'Mounted on']
return lines[0] if lines else '/srv'
@app.route('/api/quota', methods=['POST']) @app.route('/api/quota', methods=['POST'])
@require_auth @require_auth
def set_quota(): def set_quota():
data = request.json or {} data = request.json or {}
user = data.get('user', '') user = data.get('user', '')
quota_type = data.get('type', 'smb')
raw_quota = data.get('quota', 0) raw_quota = data.get('quota', 0)
path = data.get('path', '/srv/samba') path = data.get('path', '/srv')
if not VALID_NAME.match(user): if not VALID_NAME.match(user):
return jsonify({'error': 'invalid username'}), 400 return jsonify({'error': 'invalid username'}), 400
@@ -435,17 +444,23 @@ def set_quota():
except (ValueError, TypeError): except (ValueError, TypeError):
return jsonify({'error': 'quota must be a positive integer (GB)'}), 400 return jsonify({'error': 'quota must be a positive integer (GB)'}), 400
if quota_type == 'smb':
mount = '/srv/samba'
else:
try: try:
mount = validate_path(path) safe_path = validate_path(path)
except ValueError as e: except ValueError as e:
return jsonify({'error': str(e)}), 400 return jsonify({'error': str(e)}), 400
mount = _find_mountpoint(safe_path)
soft_kb = str(quota_gb * 1024 * 1024) soft_kb = str(quota_gb * 1024 * 1024)
hard_kb = str((quota_gb + 1) * 1024 * 1024) hard_kb = str((quota_gb + 1) * 1024 * 1024)
subprocess.run(['setquota', '-u', user, soft_kb, hard_kb, '0', '0', mount], check=True) try:
r = subprocess.run(
['setquota', '-u', user, soft_kb, hard_kb, '0', '0', mount],
capture_output=True, text=True,
)
if r.returncode != 0:
return jsonify({'error': r.stderr.strip() or 'setquota failed'}), 500
except FileNotFoundError:
return jsonify({'error': 'setquota not found — install quota package'}), 500
return jsonify({'status': 'ok'}) return jsonify({'status': 'ok'})
+11 -5
View File
@@ -45,25 +45,31 @@ async function submitQuota() {
for (const inp of document.querySelectorAll('.quota-inp')) { for (const inp of document.querySelectorAll('.quota-inp')) {
const gb = parseInt(inp.value); const gb = parseInt(inp.value);
if (!gb || gb <= 0) continue; if (!gb || gb <= 0) continue;
calls.push({ type: 'smb', user: inp.dataset.user, quota: gb }); calls.push({ user: inp.dataset.user, quota: gb, path: _qCtx.path });
} }
} else { } else {
const user = document.getElementById('qManualUser')?.value.trim(); const user = document.getElementById('qManualUser')?.value.trim();
const gb = parseInt(document.getElementById('qManualGB')?.value); const gb = parseInt(document.getElementById('qManualGB')?.value);
if (!user) return showToast('Username is required.', 'danger'); if (!user) return showToast('Username is required.', 'danger');
if (!gb || gb <= 0) return showToast('Quota must be a positive number.', 'danger'); if (!gb || gb <= 0) return showToast('Quota must be a positive number.', 'danger');
calls.push({ type: _qCtx.type, user, path: _qCtx.path, quota: gb }); calls.push({ user, path: _qCtx.path || '/srv', quota: gb });
} }
if (!calls.length) return showToast('No quota values entered.', 'warning'); if (!calls.length) return showToast('No quota values entered.', 'warning');
bootstrap.Modal.getInstance(document.getElementById('quotaModal')).hide(); bootstrap.Modal.getInstance(document.getElementById('quotaModal')).hide();
const results = await Promise.all(calls.map(body => const responses = await Promise.all(calls.map(body =>
apiFetch('/api/quota', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) }) apiFetch('/api/quota', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) })
)); ));
const failed = results.filter(r => r && !r.ok).length; const errors = [];
if (failed) showToast(`${failed} quota(s) failed to apply.`, 'danger'); for (const r of responses) {
if (r && !r.ok) {
const d = await r.json().catch(() => ({}));
errors.push(d.error || `HTTP ${r.status}`);
}
}
if (errors.length) showToast('Quota error: ' + errors.join('; '), 'danger');
else showToast(`Quota${calls.length > 1 ? 's' : ''} applied.`); else showToast(`Quota${calls.length > 1 ? 's' : ''} applied.`);
loadDashboard(); loadDashboard();