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

+3 -2
View File
@@ -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); }
+4 -3
View File
@@ -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 => `
<tr>
<td><span class="badge rounded-pill ${s.type === 'smb' ? 'badge-smb' : 'badge-nfs'}">${s.type.toUpperCase()}</span></td>
<td><span class="badge rounded-pill badge-${s.type}">${s.type.toUpperCase()}</span></td>
<td>${esc(s.name ?? '—')}</td>
<td><code>${esc(s.path)}</code></td>
<td style="color:var(--muted); font-size:.87em">${esc(s.access || '—')}</td>
+71 -1
View File
@@ -30,6 +30,29 @@ async function loadShares() {
}).join('');
}
const wb = document.getElementById('webdavTableBody');
if (!shares.webdav?.length) {
wb.innerHTML = `<tr><td colspan="5" style="color:var(--muted)">No WebDAV shares.</td></tr>`;
} 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 `<tr>
<td>${esc(s.name)}</td>
<td><code>${esc(s.path)}</code></td>
<td style="color:var(--muted); font-size:.87em">${esc(s.users || '— all —')}</td>
<td><a href="${url}" target="_blank" style="color:var(--accent); font-size:.85em">${url}</a></td>
<td>
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
onclick="openEditModal('webdav',${shareJson})">Edit</button>
<button class="btn btn-sm btn-danger"
onclick="deleteShare('webdav',${nameJson})">Delete</button>
</td>
</tr>`;
}).join('');
}
const nb = document.getElementById('nfsTableBody');
if (!shares.nfs?.length) {
nb.innerHTML = `<tr><td colspan="4" style="color:var(--muted)">No NFS exports.</td></tr>`;
@@ -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) {
<label class="form-check-label" for="editRecycleBin" style="color:var(--muted)">Recycle Bin — deleted files go to <code>.recycle</code> instead of being gone</label>
</div>
${permSection}`;
} else if (type === 'webdav') {
body = `
<div class="mb-3">
<label class="form-label" style="color:var(--muted); font-size:.85rem">Share Name</label>
<input type="text" id="editName" class="form-control" value="${esc(share.name)}">
</div>
<div class="mb-3">
<label class="form-label" style="color:var(--muted); font-size:.85rem">Path</label>
<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">Allowed Users
<span style="font-weight:400"> — space-separated, empty = all WebDAV users</span>
</label>
<input type="text" id="editWebdavUsers" class="form-control" value="${esc(share.users || '')}">
</div>
${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'},