diff --git a/app.py b/app.py index 35fd368..cb8c7a6 100644 --- a/app.py +++ b/app.py @@ -475,6 +475,83 @@ def set_permissions(): return jsonify({'status': 'ok', 'stat': _path_stat(safe_path)}) +# ===== GROUPS ===== + +_GROUP_WHITELIST = {'root', 'staff', 'sambashare', 'www-data'} + + +def _list_groups(): + groups = [] + with open('/etc/group', 'r') as f: + for line in f: + parts = line.strip().split(':') + if len(parts) < 4: + continue + name, gid_str, members_str = parts[0], parts[2], parts[3] + try: + gid = int(gid_str) + except ValueError: + continue + members = [m for m in members_str.split(',') if m] + if gid >= 1000 or name in _GROUP_WHITELIST: + groups.append({'name': name, 'gid': gid, 'members': members}) + return sorted(groups, key=lambda g: g['name']) + + +@app.route('/api/groups', methods=['GET']) +@require_auth +def list_groups(): + return jsonify(_list_groups()) + + +@app.route('/api/groups', methods=['POST']) +@require_auth +def create_group(): + name = (request.json or {}).get('name', '') + if not VALID_NAME.match(name): + return jsonify({'error': 'invalid group name'}), 400 + r = subprocess.run(['groupadd', name], capture_output=True, text=True) + if r.returncode != 0: + return jsonify({'error': r.stderr.strip() or 'groupadd failed'}), 400 + return jsonify({'status': 'created'}) + + +@app.route('/api/groups/', methods=['DELETE']) +@require_auth +def delete_group(name): + if not VALID_NAME.match(name): + return jsonify({'error': 'invalid group name'}), 400 + r = subprocess.run(['groupdel', name], capture_output=True, text=True) + if r.returncode != 0: + return jsonify({'error': r.stderr.strip() or 'groupdel failed'}), 500 + return jsonify({'status': 'deleted'}) + + +@app.route('/api/groups//members', methods=['POST']) +@require_auth +def add_group_member(name): + if not VALID_NAME.match(name): + return jsonify({'error': 'invalid group name'}), 400 + user = (request.json or {}).get('user', '') + if not VALID_NAME.match(user): + return jsonify({'error': 'invalid username'}), 400 + r = subprocess.run(['gpasswd', '-a', user, name], capture_output=True, text=True) + if r.returncode != 0: + return jsonify({'error': r.stderr.strip() or 'gpasswd failed'}), 500 + return jsonify({'status': 'added'}) + + +@app.route('/api/groups//members/', methods=['DELETE']) +@require_auth +def remove_group_member(name, user): + if not VALID_NAME.match(name) or not VALID_NAME.match(user): + return jsonify({'error': 'invalid name'}), 400 + r = subprocess.run(['gpasswd', '-d', user, name], capture_output=True, text=True) + if r.returncode != 0: + return jsonify({'error': r.stderr.strip() or 'gpasswd failed'}), 500 + return jsonify({'status': 'removed'}) + + # ===== USERS ===== def _smb_users(): diff --git a/static/js/main.js b/static/js/main.js index b82f79c..0170afe 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -17,4 +17,5 @@ function _startRefreshCountdown() { loadDashboard(); loadShares(); loadUsers(); -setInterval(() => { loadDashboard(); loadShares(); loadUsers(); }, REFRESH_INTERVAL * 1000); +loadGroups(); +setInterval(() => { loadDashboard(); loadShares(); loadUsers(); loadGroups(); }, REFRESH_INTERVAL * 1000); diff --git a/static/js/shares.js b/static/js/shares.js index 28d896d..5a44113 100644 --- a/static/js/shares.js +++ b/static/js/shares.js @@ -100,22 +100,35 @@ async function addNFSShare() { let _editCtx = null; -function openEditModal(type, share) { +async function openEditModal(type, share) { _editCtx = { type, original: share }; document.getElementById('editModalTitle').textContent = type === 'smb' ? `Edit SMB Share — ${esc(share.name)}` : `Edit NFS Export — ${esc(share.path)}`; + document.getElementById('editModalBody').innerHTML = + `
Loading…
`; + new bootstrap.Modal(document.getElementById('editModal')).show(); + + const [ur, gr] = await Promise.all([apiFetch('/api/users'), apiFetch('/api/groups')]); + const users = ur ? await ur.json() : []; + const groups = gr ? await gr.json() : []; + + const ownerOptions = ['root', ...users.map(u => u.name)] + .map(n => `