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
+13
-3
@@ -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://<server>/dav/<name>` (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).
|
||||
|
||||
|
||||
@@ -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 <nas-ip>:/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://<nas-ip>/dav/<sharename>` (port 80).
|
||||
|
||||
```bash
|
||||
# Mount WebDAV on Linux (davfs2)
|
||||
apt install davfs2
|
||||
mount -t davfs http://<nas-ip>/dav/photos /mnt/webdav -o username=alice
|
||||
|
||||
# Permanent — /etc/fstab:
|
||||
http://<nas-ip>/dav/photos /mnt/webdav davfs username=alice,_netdev 0 0
|
||||
# Store password in /etc/davfs2/secrets:
|
||||
http://<nas-ip>/dav/photos alice yourpassword
|
||||
```
|
||||
|
||||
On **Windows**: open File Explorer → "Map network drive" → `\\<nas-ip>\DavWWWRoot\dav\photos` (WebDAV via WebClient service), or use `http://<nas-ip>/dav/photos` directly in the address bar.
|
||||
|
||||
On **Mac**: Finder → Go → Connect to Server → `http://<nas-ip>/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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+3
-2
@@ -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); }
|
||||
|
||||
@@ -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
@@ -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'},
|
||||
|
||||
+41
-2
@@ -88,6 +88,9 @@
|
||||
<li class="nav-item">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#nfs" type="button">NFS Exports</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#webdav" type="button">WebDAV</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#users" type="button">Users</button>
|
||||
</li>
|
||||
@@ -98,7 +101,7 @@
|
||||
<!-- DASHBOARD -->
|
||||
<div class="tab-pane fade show active" id="dashboard">
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-2">
|
||||
<div class="col-4 col-md-2">
|
||||
<div class="card h-100">
|
||||
<div class="card-body py-3 px-3">
|
||||
<div class="card-label">smbd</div>
|
||||
@@ -108,7 +111,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<div class="col-4 col-md-2">
|
||||
<div class="card h-100">
|
||||
<div class="card-body py-3 px-3">
|
||||
<div class="card-label">nfsd</div>
|
||||
@@ -118,6 +121,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4 col-md-2">
|
||||
<div class="card h-100">
|
||||
<div class="card-body py-3 px-3">
|
||||
<div class="card-label">apache2</div>
|
||||
<div class="d-flex align-items-center mt-1" id="statusApache2">
|
||||
<span class="dot dot-red"></span><span class="small">…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-8">
|
||||
<div class="card h-100">
|
||||
<div class="card-body py-3 px-3">
|
||||
@@ -211,6 +224,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WEBDAV -->
|
||||
<div class="tab-pane fade" id="webdav">
|
||||
<div class="fw-semibold mb-3">Add WebDAV Share</div>
|
||||
<div class="row g-2 mb-4">
|
||||
<div class="col-md-2"><input type="text" id="webdavName" class="form-control" placeholder="Share name"></div>
|
||||
<div class="col-md-4"><input type="text" id="webdavPath" class="form-control" placeholder="/srv/data"></div>
|
||||
<div class="col-md-3"><input type="text" id="webdavUsers" class="form-control" placeholder="alice bob (empty = all)"></div>
|
||||
<div class="col-md-3"><button type="button" class="btn btn-success w-100" onclick="addWebDAVShare()">Add</button></div>
|
||||
</div>
|
||||
<div class="fw-semibold mb-2">Shares</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped mb-0">
|
||||
<thead><tr><th>Name</th><th>Path</th><th>Users</th><th>URL</th><th>Actions</th></tr></thead>
|
||||
<tbody id="webdavTableBody">
|
||||
<tr><td colspan="5" style="color:var(--muted)">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-3" style="font-size:.82rem; color:var(--muted)">
|
||||
Requires <code>apache2</code> + <code>apache2-utils</code> installed and
|
||||
<code>mod_dav</code> / <code>mod_dav_fs</code> enabled.
|
||||
Shares are served at <code>http://<server-ip>/dav/<name></code>.
|
||||
User passwords are synced automatically from the Users tab.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- USERS -->
|
||||
<div class="tab-pane fade" id="users">
|
||||
<div class="fw-semibold mb-3">Create User</div>
|
||||
|
||||
Reference in new issue
Block a user