diff --git a/app.py b/app.py index 9933025..111f0ee 100644 --- a/app.py +++ b/app.py @@ -417,14 +417,23 @@ def toggle_nfs(): # ===== 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']) @require_auth def set_quota(): data = request.json or {} user = data.get('user', '') - quota_type = data.get('type', 'smb') raw_quota = data.get('quota', 0) - path = data.get('path', '/srv/samba') + path = data.get('path', '/srv') if not VALID_NAME.match(user): return jsonify({'error': 'invalid username'}), 400 @@ -435,17 +444,23 @@ def set_quota(): except (ValueError, TypeError): return jsonify({'error': 'quota must be a positive integer (GB)'}), 400 - if quota_type == 'smb': - mount = '/srv/samba' - else: - try: - mount = validate_path(path) - except ValueError as e: - return jsonify({'error': str(e)}), 400 + try: + safe_path = validate_path(path) + except ValueError as e: + return jsonify({'error': str(e)}), 400 + mount = _find_mountpoint(safe_path) soft_kb = str(quota_gb * 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'}) diff --git a/static/js/quotas.js b/static/js/quotas.js index 0d82771..eff8ee5 100644 --- a/static/js/quotas.js +++ b/static/js/quotas.js @@ -45,25 +45,31 @@ async function submitQuota() { for (const inp of document.querySelectorAll('.quota-inp')) { const gb = parseInt(inp.value); 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 { const user = document.getElementById('qManualUser')?.value.trim(); const gb = parseInt(document.getElementById('qManualGB')?.value); if (!user) return showToast('Username is required.', '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'); 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) }) )); - const failed = results.filter(r => r && !r.ok).length; - if (failed) showToast(`${failed} quota(s) failed to apply.`, 'danger'); + const errors = []; + 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.`); loadDashboard();