diff --git a/app.py b/app.py index 84084f7..35fd368 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,7 @@ import os import re +import pwd +import grp import shutil import hashlib import subprocess @@ -55,6 +57,19 @@ def validate_path(path_str): return str(p) +def _path_stat(path): + """Return owner, group, and octal mode for a path. Empty strings on error.""" + try: + s = os.stat(path) + return { + 'owner': pwd.getpwuid(s.st_uid).pw_name, + 'group': grp.getgrgid(s.st_gid).gr_name, + 'mode': oct(s.st_mode)[-3:], + } + except Exception: + return {'owner': '', 'group': '', 'mode': ''} + + def _validate_users(valid_users): """Validate and return a cleaned comma-separated user list, or raise ValueError.""" users = [] @@ -235,7 +250,13 @@ def index(): @app.route('/api/shares', methods=['GET']) @require_auth def get_all_shares(): - return jsonify({'smb': parse_smb(), 'nfs': get_nfs_shares()}) + smb = parse_smb() + for s in smb: + s.update(_path_stat(s['path'])) + nfs = get_nfs_shares() + for s in nfs: + s.update(_path_stat(s['path'])) + return jsonify({'smb': smb, 'nfs': nfs}) # ===== SMB CRUD ===== @@ -418,6 +439,42 @@ def toggle_nfs(): return jsonify({'status': 'enabled' if enabled else 'disabled'}) +# ===== PERMISSIONS ===== + +@app.route('/api/permissions', methods=['POST']) +@require_auth +def set_permissions(): + data = request.json or {} + path = data.get('path', '') + owner = data.get('owner', '').strip() + group = data.get('group', '').strip() + mode = data.get('mode', '').strip() + + try: + safe_path = validate_path(path) + except ValueError as e: + return jsonify({'error': str(e)}), 400 + if owner and not VALID_NAME.match(owner): + return jsonify({'error': 'invalid owner name'}), 400 + if group and not VALID_NAME.match(group): + return jsonify({'error': 'invalid group name'}), 400 + if mode and not re.match(r'^[0-7]{3,4}$', mode): + return jsonify({'error': 'mode must be 3-4 octal digits (e.g. 770)'}), 400 + + if owner or group: + chown_arg = f'{owner}:{group}' if (owner and group) else (owner or f':{group}') + r = subprocess.run(['chown', chown_arg, safe_path], capture_output=True, text=True) + if r.returncode != 0: + return jsonify({'error': r.stderr.strip() or 'chown failed'}), 500 + + if mode: + r = subprocess.run(['chmod', mode, safe_path], capture_output=True, text=True) + if r.returncode != 0: + return jsonify({'error': r.stderr.strip() or 'chmod failed'}), 500 + + return jsonify({'status': 'ok', 'stat': _path_stat(safe_path)}) + + # ===== USERS ===== def _smb_users(): diff --git a/static/js/shares.js b/static/js/shares.js index 0eed50c..28d896d 100644 --- a/static/js/shares.js +++ b/static/js/shares.js @@ -105,6 +105,30 @@ function openEditModal(type, share) { document.getElementById('editModalTitle').textContent = type === 'smb' ? `Edit SMB Share — ${esc(share.name)}` : `Edit NFS Export — ${esc(share.path)}`; + const permSection = ` +
+
Folder Permissions
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ Common: 770 owner+group rw  ·  + 750 group read-only  ·  + 755 world read  ·  + Use @groupname in Valid Users to allow a whole group. +
`; + let body = ''; if (type === 'smb') { body = ` @@ -117,9 +141,12 @@ function openEditModal(type, share) {
- + -
`; + + ${permSection}`; } else { const isRo = /\(ro[,)]/.test(share.clients); const cleanClients = share.clients.replace(/\s*\(.*?\)/g, '').trim(); @@ -132,10 +159,11 @@ function openEditModal(type, share) { -
+
-
`; +
+ ${permSection}`; } document.getElementById('editModalBody').innerHTML = body; @@ -166,8 +194,32 @@ async function submitEdit() { if (!res) return; const data = await res.json(); + if (!res.ok) { + bootstrap.Modal.getInstance(document.getElementById('editModal')).hide(); + return showToast('Error: ' + (data.error || res.status), 'danger'); + } + + // Apply permissions if any field is filled + const owner = document.getElementById('editOwner')?.value.trim(); + const group = document.getElementById('editGroup')?.value.trim(); + const mode = document.getElementById('editMode')?.value.trim(); + const permPath = ctx.type === 'smb' + ? document.getElementById('editPath')?.value.trim() + : ctx.original.path; + + if (owner || group || mode) { + const pr = await apiFetch('/api/permissions', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({path: permPath, owner, group, mode}), + }); + if (pr && !pr.ok) { + const pd = await pr.json(); + bootstrap.Modal.getInstance(document.getElementById('editModal')).hide(); + return showToast('Permission error: ' + (pd.error || pr.status), 'danger'); + } + } + bootstrap.Modal.getInstance(document.getElementById('editModal')).hide(); - if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger'); showToast('Share updated.'); loadShares(); loadDashboard(); }