Add folder permission editor to share edit modal
Backend: - Import pwd/grp for uid/gid → name resolution - _path_stat(path): returns current owner, group, octal mode via os.stat - get_all_shares: attaches stat info to each share so the modal can pre-fill current values - POST /api/permissions: validates and applies chown/chmod to a share path; returns updated stat on success Frontend: - Edit modal now has a Permissions section (Owner / Group / Mode) pre-filled with current values, with a hint line showing common modes - Valid Users label notes @groupname syntax for Samba group access - submitEdit applies permissions after saving the share config if any permission field is non-empty; shows specific error on failure Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
e03905b26d
commit
302a5cb023
2 files changed
+115
-6
No files matched your search
@@ -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():
|
||||
|
||||
+57
-5
@@ -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 = `
|
||||
<hr style="border-color:var(--border); margin:1.25rem 0 1rem">
|
||||
<div class="fw-semibold mb-3" style="font-size:.9rem; color:var(--text)">Folder Permissions</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-5">
|
||||
<label class="form-label" style="color:var(--muted); font-size:.85rem">Owner</label>
|
||||
<input type="text" id="editOwner" class="form-control" value="${esc(share.owner || '')}" placeholder="root">
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<label class="form-label" style="color:var(--muted); font-size:.85rem">Group</label>
|
||||
<input type="text" id="editGroup" class="form-control" value="${esc(share.group || '')}" placeholder="root">
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<label class="form-label" style="color:var(--muted); font-size:.85rem">Mode</label>
|
||||
<input type="text" id="editMode" class="form-control" value="${esc(share.mode || '')}" placeholder="770" maxlength="4">
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:.8rem; color:var(--muted)">
|
||||
Common: <code>770</code> owner+group rw ·
|
||||
<code>750</code> group read-only ·
|
||||
<code>755</code> world read ·
|
||||
Use <code>@groupname</code> in Valid Users to allow a whole group.
|
||||
</div>`;
|
||||
|
||||
let body = '';
|
||||
if (type === 'smb') {
|
||||
body = `
|
||||
@@ -117,9 +141,12 @@ function openEditModal(type, share) {
|
||||
<input type="text" id="editPath" class="form-control" value="${esc(share.path)}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" style="color:var(--muted); font-size:.85rem">Valid Users</label>
|
||||
<label class="form-label" style="color:var(--muted); font-size:.85rem">Valid Users
|
||||
<span style="font-weight:400"> — use <code>@groupname</code> for a whole group</span>
|
||||
</label>
|
||||
<input type="text" id="editUsers" class="form-control" value="${esc(share.valid_users || '')}">
|
||||
</div>`;
|
||||
</div>
|
||||
${permSection}`;
|
||||
} else {
|
||||
const isRo = /\(ro[,)]/.test(share.clients);
|
||||
const cleanClients = share.clients.replace(/\s*\(.*?\)/g, '').trim();
|
||||
@@ -132,10 +159,11 @@ function openEditModal(type, share) {
|
||||
<label class="form-label" style="color:var(--muted); font-size:.85rem">Clients</label>
|
||||
<input type="text" id="editClients" class="form-control" value="${esc(cleanClients)}">
|
||||
</div>
|
||||
<div class="form-check ms-1 mt-2">
|
||||
<div class="form-check ms-1 mt-2 mb-2">
|
||||
<input type="checkbox" id="editReadOnly" class="form-check-input" ${isRo ? 'checked' : ''}>
|
||||
<label class="form-check-label" for="editReadOnly" style="color:var(--muted)">Read only</label>
|
||||
</div>`;
|
||||
</div>
|
||||
${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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user