Add WebDAV support via Apache mod_dav
Backend: - WebDAV config managed at /etc/apache2/conf-available/micronas-webdav.conf with BEGIN/END markers per share; reload apache2 on every write - htpasswd file at /etc/micronas-webdav.passwd synced automatically on create_user, change_password, delete_user - Full CRUD: POST/PATCH/DELETE /api/share/webdav[/<name>] - _validate_webdav_users(): space-separated, no @group (not supported by Apache) - Fix: _validate_users() now allows @groupname for SMB (was incorrectly rejected) - Dashboard: apache2 status + restart button; webdav shares in /api/dashboard Frontend: - WebDAV tab: create form + table with clickable access URLs - Edit modal supports 'webdav' type (name, path, users, permissions section) - badge-webdav CSS class (green tint) in app.css - dashboard.js renders badge-<type> dynamically (smb/nfs/webdav) Docs: README WebDAV mount guide (Linux davfs2, Windows, Mac), PLANNING phase 4 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
1be6a05cef
commit
7c9fe07a17
7 files changed
+338
-19
No files matched your search
@@ -70,16 +70,31 @@ def _path_stat(path):
|
||||
return {'owner': '', 'group': '', 'mode': ''}
|
||||
|
||||
|
||||
_VALID_SMB_ENTRY = re.compile(r'^@?[A-Za-z0-9_-]+$') # allows @groupname for SMB
|
||||
|
||||
|
||||
def _validate_users(valid_users):
|
||||
"""Validate and return a cleaned comma-separated user list, or raise ValueError."""
|
||||
"""Validate and return a cleaned comma-separated user list (SMB: allows @group)."""
|
||||
users = []
|
||||
for u in re.split(r'[\s,]+', valid_users):
|
||||
if not u:
|
||||
continue
|
||||
if not _VALID_SMB_ENTRY.match(u):
|
||||
raise ValueError(f'invalid username: {u!r}')
|
||||
users.append(u)
|
||||
return ', '.join(users)
|
||||
|
||||
|
||||
def _validate_webdav_users(valid_users):
|
||||
"""Validate and return space-separated WebDAV usernames (no @groups)."""
|
||||
users = []
|
||||
for u in re.split(r'[\s,]+', valid_users):
|
||||
if not u:
|
||||
continue
|
||||
if not VALID_NAME.match(u):
|
||||
raise ValueError(f'invalid username: {u}')
|
||||
raise ValueError(f'invalid WebDAV username: {u!r} (no groups)')
|
||||
users.append(u)
|
||||
return ', '.join(users)
|
||||
return ' '.join(users)
|
||||
|
||||
|
||||
# ===== SMB =====
|
||||
@@ -239,6 +254,146 @@ def add_nfs_share(path, clients, ro=False):
|
||||
subprocess.run(['exportfs', '-ra'], check=True)
|
||||
|
||||
|
||||
# ===== WEBDAV =====
|
||||
|
||||
WEBDAV_CONF = '/etc/apache2/conf-available/micronas-webdav.conf'
|
||||
WEBDAV_PASSWD = '/etc/micronas-webdav.passwd'
|
||||
_WEBDAV_MARK = 'MICRONAS'
|
||||
|
||||
|
||||
def _parse_webdav_conf():
|
||||
shares = []
|
||||
try:
|
||||
content = Path(WEBDAV_CONF).read_text()
|
||||
except FileNotFoundError:
|
||||
return shares
|
||||
pattern = re.compile(
|
||||
r'# BEGIN ' + _WEBDAV_MARK + r' (\S+)\n(.*?)# END ' + _WEBDAV_MARK + r' \1',
|
||||
re.DOTALL,
|
||||
)
|
||||
for m in pattern.finditer(content):
|
||||
name = m.group(1)
|
||||
block = m.group(2)
|
||||
alias_m = re.search(r'^Alias\s+\S+\s+(\S+)', block, re.M)
|
||||
users_m = re.search(r'Require user (.+)$', block, re.M)
|
||||
shares.append({
|
||||
'name': name,
|
||||
'path': alias_m.group(1).strip() if alias_m else '',
|
||||
'users': users_m.group(1).strip() if users_m else '',
|
||||
})
|
||||
return shares
|
||||
|
||||
|
||||
def _write_webdav_conf(shares):
|
||||
os.makedirs('/var/lib/apache2/webdav', exist_ok=True)
|
||||
lines = ['# Managed by MicroNAS — do not edit manually\n\n',
|
||||
'DavLockDB /var/lib/apache2/webdav/DavLock\n\n']
|
||||
for s in shares:
|
||||
require = f'Require user {s["users"]}' if s.get('users') else 'Require valid-user'
|
||||
lines += [
|
||||
f'# BEGIN {_WEBDAV_MARK} {s["name"]}\n',
|
||||
f'Alias /dav/{s["name"]} {s["path"]}\n',
|
||||
f'<Directory {s["path"]}>\n',
|
||||
' DAV On\n',
|
||||
' AuthType Basic\n',
|
||||
' AuthName "MicroNAS WebDAV"\n',
|
||||
f' AuthUserFile {WEBDAV_PASSWD}\n',
|
||||
f' {require}\n',
|
||||
' Options None\n',
|
||||
' AllowOverride None\n',
|
||||
'</Directory>\n',
|
||||
f'# END {_WEBDAV_MARK} {s["name"]}\n\n',
|
||||
]
|
||||
with open(WEBDAV_CONF, 'w') as f:
|
||||
f.writelines(lines)
|
||||
subprocess.run(['a2enconf', 'micronas-webdav'], capture_output=True)
|
||||
subprocess.run(['systemctl', 'reload', 'apache2'], capture_output=True)
|
||||
|
||||
|
||||
def _set_webdav_password(name, password):
|
||||
"""Add or update a user in the WebDAV htpasswd file. Silent if htpasswd not installed."""
|
||||
flag = '-ci' if not os.path.exists(WEBDAV_PASSWD) else '-i'
|
||||
try:
|
||||
subprocess.run(['htpasswd', flag, WEBDAV_PASSWD, name],
|
||||
input=password, capture_output=True, text=True)
|
||||
except FileNotFoundError:
|
||||
pass # apache2-utils not installed — skip
|
||||
|
||||
|
||||
def _remove_webdav_user(name):
|
||||
if os.path.exists(WEBDAV_PASSWD):
|
||||
try:
|
||||
subprocess.run(['htpasswd', '-D', WEBDAV_PASSWD, name], capture_output=True)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
@app.route('/api/share/webdav', methods=['POST'])
|
||||
@require_auth
|
||||
def create_webdav():
|
||||
data = request.json or {}
|
||||
name = data.get('name', '').strip()
|
||||
path = data.get('path', '')
|
||||
users = data.get('users', '')
|
||||
|
||||
if not VALID_NAME.match(name):
|
||||
return jsonify({'error': 'invalid share name'}), 400
|
||||
try:
|
||||
safe_path = validate_path(path)
|
||||
clean_users = _validate_webdav_users(users) if users else ''
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
|
||||
shares = _parse_webdav_conf()
|
||||
if any(s['name'] == name for s in shares):
|
||||
return jsonify({'error': f'share {name!r} already exists'}), 400
|
||||
shares.append({'name': name, 'path': safe_path, 'users': clean_users})
|
||||
_write_webdav_conf(shares)
|
||||
return jsonify({'status': 'created'})
|
||||
|
||||
|
||||
@app.route('/api/share/webdav/<name>', methods=['PATCH'])
|
||||
@require_auth
|
||||
def edit_webdav(name):
|
||||
data = request.json or {}
|
||||
new_name = data.get('name', name).strip()
|
||||
path = data.get('path', '')
|
||||
users = data.get('users', '')
|
||||
|
||||
if not VALID_NAME.match(new_name):
|
||||
return jsonify({'error': 'invalid share name'}), 400
|
||||
try:
|
||||
safe_path = validate_path(path)
|
||||
clean_users = _validate_webdav_users(users) if users else ''
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
|
||||
shares = _parse_webdav_conf()
|
||||
for s in shares:
|
||||
if s['name'] == name:
|
||||
s['name'] = new_name
|
||||
s['path'] = safe_path
|
||||
s['users'] = clean_users
|
||||
break
|
||||
else:
|
||||
return jsonify({'error': f'share {name!r} not found'}), 404
|
||||
_write_webdav_conf(shares)
|
||||
return jsonify({'status': 'updated'})
|
||||
|
||||
|
||||
@app.route('/api/share/webdav/<name>', methods=['DELETE'])
|
||||
@require_auth
|
||||
def delete_webdav(name):
|
||||
if not VALID_NAME.match(name):
|
||||
return jsonify({'error': 'invalid share name'}), 400
|
||||
shares = _parse_webdav_conf()
|
||||
filtered = [s for s in shares if s['name'] != name]
|
||||
if len(filtered) == len(shares):
|
||||
return jsonify({'error': f'share {name!r} not found'}), 404
|
||||
_write_webdav_conf(filtered)
|
||||
return jsonify({'status': 'deleted'})
|
||||
|
||||
|
||||
# ===== AUTH ROUTES =====
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
@@ -279,7 +434,10 @@ def get_all_shares():
|
||||
nfs = get_nfs_shares()
|
||||
for s in nfs:
|
||||
s.update(_path_stat(s['path']))
|
||||
return jsonify({'smb': smb, 'nfs': nfs})
|
||||
webdav = _parse_webdav_conf()
|
||||
for s in webdav:
|
||||
s.update(_path_stat(s['path']))
|
||||
return jsonify({'smb': smb, 'nfs': nfs, 'webdav': webdav})
|
||||
|
||||
|
||||
# ===== SMB CRUD =====
|
||||
@@ -667,7 +825,7 @@ def create_user():
|
||||
_set_smb_password(name, password)
|
||||
except RuntimeError as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
_set_webdav_password(name, password)
|
||||
return jsonify({'status': 'created'})
|
||||
|
||||
|
||||
@@ -677,6 +835,7 @@ def delete_user(name):
|
||||
if not VALID_NAME.match(name):
|
||||
return jsonify({'error': 'invalid username'}), 400
|
||||
subprocess.run(['smbpasswd', '-x', name], capture_output=True)
|
||||
_remove_webdav_user(name)
|
||||
try:
|
||||
subprocess.run(['userdel', name], check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
@@ -697,6 +856,7 @@ def change_password(name):
|
||||
_set_smb_password(name, password)
|
||||
except RuntimeError as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
_set_webdav_password(name, password)
|
||||
return jsonify({'status': 'updated'})
|
||||
|
||||
|
||||
@@ -706,7 +866,11 @@ def _daemon_status():
|
||||
def is_active(service):
|
||||
r = subprocess.run(['systemctl', 'is-active', service], capture_output=True, text=True)
|
||||
return r.stdout.strip() == 'active'
|
||||
return {'smbd': is_active('smbd'), 'nfsd': is_active('nfs-server')}
|
||||
return {
|
||||
'smbd': is_active('smbd'),
|
||||
'nfsd': is_active('nfs-server'),
|
||||
'apache2': is_active('apache2'),
|
||||
}
|
||||
|
||||
|
||||
def _server_ips():
|
||||
@@ -751,7 +915,7 @@ def _active_connections():
|
||||
@app.route('/api/service/<name>/restart', methods=['POST'])
|
||||
@require_auth
|
||||
def restart_service(name):
|
||||
service_map = {'smbd': 'smbd', 'nfsd': 'nfs-server'}
|
||||
service_map = {'smbd': 'smbd', 'nfsd': 'nfs-server', 'apache2': 'apache2'}
|
||||
if name not in service_map:
|
||||
return jsonify({'error': 'unknown service'}), 400
|
||||
r = subprocess.run(['systemctl', 'restart', service_map[name]], capture_output=True, text=True)
|
||||
@@ -799,6 +963,12 @@ def _shares_with_usage():
|
||||
'access': s['clients'], 'enabled': s['enabled'],
|
||||
'size_gb': round(_du_bytes(s['path']) / 1024**3, 2) if s['path'] else 0,
|
||||
})
|
||||
for s in _parse_webdav_conf():
|
||||
result.append({
|
||||
'type': 'webdav', 'name': s['name'], 'path': s['path'],
|
||||
'access': s['users'] or 'all', 'enabled': True,
|
||||
'size_gb': round(_du_bytes(s['path']) / 1024**3, 2) if s['path'] else 0,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user