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:
marcandClaude Sonnet 4.6 committed 2026-06-20 01:22:19 +02:00
1 parent 1be6a05cef
commit 7c9fe07a17
7 files changed
+338 -19

No files matched your search

+177 -7
View File
@@ -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