diff --git a/PLANNING.md b/PLANNING.md index 1f03374..cda8bb7 100644 --- a/PLANNING.md +++ b/PLANNING.md @@ -102,9 +102,19 @@ MicroNAS/ - [x] **Delete confirmation modal** — replaces browser confirm(). - [x] **Auto-refresh countdown** — 30 s timer with live seconds display. -### Phase 4 — WebDAV + extras -- [ ] **WebDAV** support. Preferred approach: drop an Apache/nginx config - snippet — don't build it in Flask. +### Phase 4 — WebDAV ✅ +- [x] Apache `mod_dav` + `mod_dav_fs` managed by MicroNAS. + Config written to `/etc/apache2/conf-available/micronas-webdav.conf` + with BEGIN/END markers per share; `a2enconf` + `systemctl reload apache2`. +- [x] Shares served at `http:///dav/` (Apache on port 80). +- [x] Auth via htpasswd (`/etc/micronas-webdav.passwd`) — passwords synced + automatically when creating/changing user passwords in the Users tab. +- [x] Full CRUD: create, edit (name/path/users/permissions), delete. +- [x] Dashboard: apache2 status dot + restart button. +- [x] WebDAV badge in dashboard shares overview. +- [x] @groupname fix in SMB valid users (was incorrectly rejected by VALID_NAME). + +### Phase 5 — Nice-to-have - [ ] Simple in-browser file browser for `/srv`. - [ ] Auto-create share directory on add (with correct owner/permissions). diff --git a/README.md b/README.md index a6b5735..4df591b 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,14 @@ Run everything below **inside the LXC container** as root. ```bash apt update -apt install -y git samba nfs-kernel-server python3 python3-venv +apt install -y git samba nfs-kernel-server apache2 apache2-utils python3 python3-venv +``` + +Enable Apache WebDAV modules: + +```bash +a2enmod dav dav_fs auth_basic authn_file +systemctl restart apache2 ``` ### 2. Clone the repo @@ -200,6 +207,27 @@ mount -t nfs :/srv/data /mnt/nas **Multiple networks (e.g. LAN + Tailscale):** enter both subnets space-separated in the Clients field when creating the export, e.g. `192.168.1.0/24 100.64.0.0/10`. Both get the same ro/rw setting. +### WebDAV + +WebDAV shares are served by Apache at `http:///dav/` (port 80). + +```bash +# Mount WebDAV on Linux (davfs2) +apt install davfs2 +mount -t davfs http:///dav/photos /mnt/webdav -o username=alice + +# Permanent — /etc/fstab: +http:///dav/photos /mnt/webdav davfs username=alice,_netdev 0 0 +# Store password in /etc/davfs2/secrets: +http:///dav/photos alice yourpassword +``` + +On **Windows**: open File Explorer → "Map network drive" → `\\\DavWWWRoot\dav\photos` (WebDAV via WebClient service), or use `http:///dav/photos` directly in the address bar. + +On **Mac**: Finder → Go → Connect to Server → `http:///dav/photos`. + +User passwords are synced automatically — when you set a password in the Users tab it is also written to the WebDAV htpasswd file. Existing users created before WebDAV was set up need their password changed once to register. + ### Useful commands ```bash diff --git a/app.py b/app.py index 12c94cd..7f00f5a 100644 --- a/app.py +++ b/app.py @@ -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'\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', + '\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/', 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/', 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//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 diff --git a/static/css/app.css b/static/css/app.css index b1805e4..ea1c1cb 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -50,8 +50,9 @@ body { .progress { background: var(--surf2); border-radius: 4px; } /* Badges */ -.badge-smb { background: #1f3a5f; color: #58a6ff; font-size: .72rem; } -.badge-nfs { background: #2d1f5f; color: #a78bfa; font-size: .72rem; } +.badge-smb { background: #1f3a5f; color: #58a6ff; font-size: .72rem; } +.badge-nfs { background: #2d1f5f; color: #a78bfa; font-size: .72rem; } +.badge-webdav { background: #1f3a2f; color: #3fb950; font-size: .72rem; } /* Modal */ .modal-content { background: var(--surf); border: 1px solid var(--border); } diff --git a/static/js/dashboard.js b/static/js/dashboard.js index 79d0e67..059e2e3 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -13,8 +13,9 @@ async function loadDashboard() { document.getElementById(id).style.display = 'flex'; document.getElementById(id).style.alignItems = 'center'; } - setDaemon('statusSmbd', 'smbd', d.status.smbd); - setDaemon('statusNfsd', 'nfsd', d.status.nfsd); + setDaemon('statusSmbd', 'smbd', d.status.smbd); + setDaemon('statusNfsd', 'nfsd', d.status.nfsd); + setDaemon('statusApache2', 'apache2', d.status.apache2); // Disk bar if (d.disk && !d.disk.error) { @@ -43,7 +44,7 @@ async function loadDashboard() { } else { sb.innerHTML = d.shares.map(s => ` - ${s.type.toUpperCase()} + ${s.type.toUpperCase()} ${esc(s.name ?? '—')} ${esc(s.path)} ${esc(s.access || '—')} diff --git a/static/js/shares.js b/static/js/shares.js index 835b2e7..d27dee9 100644 --- a/static/js/shares.js +++ b/static/js/shares.js @@ -30,6 +30,29 @@ async function loadShares() { }).join(''); } + const wb = document.getElementById('webdavTableBody'); + if (!shares.webdav?.length) { + wb.innerHTML = `No WebDAV shares.`; + } else { + wb.innerHTML = shares.webdav.map(s => { + const shareJson = esc(JSON.stringify(s)); + const nameJson = esc(JSON.stringify(s.name)); + const url = `http://${window.location.hostname}/dav/${esc(s.name)}`; + return ` + ${esc(s.name)} + ${esc(s.path)} + ${esc(s.users || '— all —')} + ${url} + + + + + `; + }).join(''); + } + const nb = document.getElementById('nfsTableBody'); if (!shares.nfs?.length) { nb.innerHTML = `No NFS exports.`; @@ -76,6 +99,26 @@ async function addSMBShare() { loadShares(); loadDashboard(); } +async function addWebDAVShare() { + const name = document.getElementById('webdavName').value.trim(); + const path = document.getElementById('webdavPath').value.trim(); + const users = document.getElementById('webdavUsers').value.trim(); + if (!name || !path) return showToast('Name and path are required.', 'danger'); + + const res = await apiFetch('/api/share/webdav', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({name, path, users}), + }); + if (!res) return; + const data = await res.json(); + if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger'); + showToast('WebDAV share created.'); + document.getElementById('webdavName').value = ''; + document.getElementById('webdavPath').value = ''; + document.getElementById('webdavUsers').value = ''; + loadShares(); loadDashboard(); +} + async function addNFSShare() { const path = document.getElementById('nfsPath').value.trim(); const clients = document.getElementById('nfsClients').value.trim() || '*'; @@ -200,6 +243,23 @@ async function openEditModal(type, share) { ${permSection}`; + } else if (type === 'webdav') { + body = ` +
+ + +
+
+ + +
+
+ + +
+ ${permSection}`; } else { const isRo = /\(ro[,)]/.test(share.clients); const cleanClients = share.clients.replace(/\s*\(.*?\)/g, '').trim(); @@ -248,6 +308,14 @@ async function submitEdit() { method: 'PATCH', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name, path, validUsers, forceUser, forceGroup, guestOk, readOnly, recycleBin}), }); + } else if (ctx.type === 'webdav') { + const name = document.getElementById('editName').value.trim(); + const path = document.getElementById('editPath').value.trim(); + const users = document.getElementById('editWebdavUsers').value.trim(); + res = await apiFetch(`/api/share/webdav/${encodeURIComponent(ctx.original.name)}`, { + method: 'PATCH', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({name, path, users}), + }); } else { const clients = document.getElementById('editClients').value.trim(); const readOnly = document.getElementById('editReadOnly').checked; @@ -270,7 +338,7 @@ async function submitEdit() { 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' + const permPath = (ctx.type === 'smb' || ctx.type === 'webdav') ? document.getElementById('editPath')?.value.trim() : ctx.original.path; @@ -306,6 +374,8 @@ function deleteShare(type, identifier) { let res; if (type === 'smb') { res = await apiFetch(`/api/share/smb/${encodeURIComponent(identifier)}`, { method: 'DELETE' }); + } else if (type === 'webdav') { + res = await apiFetch(`/api/share/webdav/${encodeURIComponent(identifier)}`, { method: 'DELETE' }); } else { res = await apiFetch('/api/share/nfs', { method: 'DELETE', headers: {'Content-Type': 'application/json'}, diff --git a/templates/index.html b/templates/index.html index 099b36b..3a087a8 100644 --- a/templates/index.html +++ b/templates/index.html @@ -88,6 +88,9 @@ + @@ -98,7 +101,7 @@
-
+
smbd
@@ -108,7 +111,7 @@
-
+
nfsd
@@ -118,6 +121,16 @@
+
+
+
+
apache2
+
+ … +
+
+
+
@@ -211,6 +224,32 @@
+ +
+
Add WebDAV Share
+
+
+
+
+
+
+
Shares
+
+ + + + + +
NamePathUsersURLActions
Loading…
+
+
+ Requires apache2 + apache2-utils installed and + mod_dav / mod_dav_fs enabled. + Shares are served at http://<server-ip>/dav/<name>. + User passwords are synced automatically from the Users tab. +
+
+
Create User