Initial commit: MicroNAS share manager (Phase 1)
This commit is contained in:
commit
f2aca2e3ea
8 files changed
+671
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
*.env
|
||||||
|
fileshare-web.env
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Project context for Claude Code. See `PLANNING.md` for the full roadmap.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
A lightweight web GUI to manage file shares on a single home server (Proxmox
|
||||||
|
LXC/VM) — a minimal NAS control panel. It manages **SMB shares**, **NFS
|
||||||
|
exports**, and **quotas** by editing host config files and reloading the
|
||||||
|
daemons. Home/LAN use only.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- `app.py` — Flask backend (API + share/export/quota logic).
|
||||||
|
- `index.html` — Bootstrap single-page UI.
|
||||||
|
- `docker-compose.yml` — optional dev/test sandbox only.
|
||||||
|
- `PLANNING.md` — roadmap and feature list.
|
||||||
|
|
||||||
|
## How it runs (target)
|
||||||
|
Directly on the LXC/VM as a `systemd` service, Flask behind `gunicorn`.
|
||||||
|
**Not** in Docker for production — the app manipulates host services
|
||||||
|
(`smbd`, `nfsd`, `/etc/exports`, `exportfs`, `setquota`), so it must run on the
|
||||||
|
host. Docker stays only as a test sandbox.
|
||||||
|
|
||||||
|
## Conventions & constraints
|
||||||
|
- **Keep it simple.** No database, no auth framework, dependency-light. Config
|
||||||
|
files (`/etc/samba/smb.conf`, `/etc/exports`) are the source of truth.
|
||||||
|
- **Security basics are non-negotiable even for home use:**
|
||||||
|
- Never use `subprocess.run(..., shell=True)` with user input. Use argument
|
||||||
|
lists.
|
||||||
|
- Validate all input: names `^[A-Za-z0-9_-]+$`; paths must resolve under
|
||||||
|
`/srv`.
|
||||||
|
- All routes require auth (single-user HTTP Basic Auth from an env var).
|
||||||
|
- **Config edits must be safe:** parse into blocks and rewrite whole files for
|
||||||
|
edit/delete — do not blindly append.
|
||||||
|
- LAN-only. Do not add internet-facing features.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
Backups (handled by Proxmox Backup Server), multi-user roles, databases,
|
||||||
|
internet exposure, multi-host management.
|
||||||
|
|
||||||
|
## Working agreement
|
||||||
|
- Work in the phases defined in `PLANNING.md`; pause after each phase for the
|
||||||
|
user to test.
|
||||||
|
- Explain changes briefly. Don't add heavy dependencies without asking.
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
# Fileshare Web — Planning
|
||||||
|
|
||||||
|
A lightweight web GUI to manage file shares on a single home server — like a
|
||||||
|
minimal NAS control panel for a Proxmox LXC/VM. Manages **SMB shares**,
|
||||||
|
**NFS exports**, and **quotas** by editing the host config files and reloading
|
||||||
|
the daemons.
|
||||||
|
|
||||||
|
**Design principle: keep it small.** Single box, home/LAN use only, no database,
|
||||||
|
no auth framework, dependency-light. If a feature needs heavy machinery, it's
|
||||||
|
out of scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment decision
|
||||||
|
|
||||||
|
Run **directly on the LXC/VM as a `systemd` service** (Flask behind `gunicorn`).
|
||||||
|
**Not** Docker.
|
||||||
|
|
||||||
|
Reason: this app's whole job is to manipulate *host* services (`smbd`, `nfsd`,
|
||||||
|
`/etc/exports`, `exportfs`, `setquota`). Containerizing it means bind-mounting
|
||||||
|
host config, running privileged, and fighting the kernel NFS server — all the
|
||||||
|
downsides of running on the host with none of the isolation benefit. Docker is
|
||||||
|
kept only as an optional dev/test sandbox.
|
||||||
|
|
||||||
|
**Network:** LAN-only. Never expose to the internet. Reach it via local network
|
||||||
|
or Tailscale. Auth is a safety net, not an internet-facing defense.
|
||||||
|
|
||||||
|
**Backups:** Out of scope. Handled at the Proxmox layer (Proxmox Backup Server
|
||||||
|
on a second client). This app does not implement backup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current state (baseline)
|
||||||
|
|
||||||
|
- `app.py` — Flask API: list/create SMB & NFS shares, set quotas.
|
||||||
|
- `index.html` — Bootstrap UI with SMB / NFS / Quotas tabs.
|
||||||
|
- `docker-compose.yml` — test sandbox.
|
||||||
|
|
||||||
|
### Known issues to fix
|
||||||
|
- `parse_smb()` is called (`app.py:13`) but never defined → `/api/shares` 500s.
|
||||||
|
- `subprocess.run(cmd, shell=True)` with f-string input (`app.py:82`) → command
|
||||||
|
injection as root.
|
||||||
|
- `add_smb_share` writes raw user input into `smb.conf` → config injection via
|
||||||
|
newlines.
|
||||||
|
- No authentication on any route.
|
||||||
|
- Config is append-only (`open(..., 'a')`) → no working delete/edit; UI Delete
|
||||||
|
buttons are wired to nothing.
|
||||||
|
- No input validation on names or paths (path traversal, share of `/`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
### Phase 1 — Correctness & safety (must-have)
|
||||||
|
- [ ] Implement `parse_smb()` so `/api/shares` works.
|
||||||
|
- [ ] Remove all `shell=True`; use argument-list `subprocess` calls.
|
||||||
|
- [ ] Validate input:
|
||||||
|
- share/user names: `^[A-Za-z0-9_-]+$`
|
||||||
|
- paths must resolve under `/srv`
|
||||||
|
- reject anything else with a clear error.
|
||||||
|
- [ ] Simple login: HTTP Basic Auth, single user + password from env var,
|
||||||
|
protecting all routes.
|
||||||
|
- [ ] `systemd` unit + `gunicorn`, plus README run instructions.
|
||||||
|
|
||||||
|
### Phase 2 — Dashboard & health (the main goal)
|
||||||
|
A landing page that shows the state of the server at a glance:
|
||||||
|
- [ ] **Daemon status** — is `smbd` running? is `nfsd` running? (green/red).
|
||||||
|
- [ ] **Disk usage** — free / used / total for `/srv` (and per-disk if simple).
|
||||||
|
- [ ] **All shares in one view** — every SMB share and NFS export, their paths,
|
||||||
|
and who can access them.
|
||||||
|
- [ ] **Per-share data used** — size on disk of each share's path (`du`).
|
||||||
|
- [ ] **Quota usage** — for each user with a quota: used vs limit, with a
|
||||||
|
progress bar (e.g. "12 GB / 50 GB").
|
||||||
|
- [ ] Auto-refresh or a manual refresh button.
|
||||||
|
|
||||||
|
### Phase 3 — Full management (complete the CRUD)
|
||||||
|
- [ ] Working **delete** for SMB & NFS (parse config into blocks, rewrite the
|
||||||
|
whole file — not append-only).
|
||||||
|
- [ ] **Edit** existing shares.
|
||||||
|
- [ ] Enable/disable a share without deleting it.
|
||||||
|
|
||||||
|
### Phase 4 — Nice-to-have
|
||||||
|
- [ ] **WebDAV** support. Preferred approach: don't build it in Flask — have the
|
||||||
|
app drop an Apache/nginx WebDAV config snippet, same pattern as SMB/NFS,
|
||||||
|
so it reuses the existing architecture.
|
||||||
|
- [ ] Simple in-browser file browser for `/srv`.
|
||||||
|
- [ ] Toast notifications instead of `alert()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope (intentionally)
|
||||||
|
- Backups (Proxmox Backup Server handles this).
|
||||||
|
- Multi-user accounts / roles (single admin login is enough).
|
||||||
|
- Database (config files are the source of truth).
|
||||||
|
- Internet exposure / TLS termination (LAN + Tailscale only).
|
||||||
|
- Clustering / multi-host management.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Fileshare Web
|
||||||
|
|
||||||
|
Lightweight web GUI to manage SMB shares, NFS exports, and quotas on a single home server (Proxmox LXC/VM). Runs directly on the host as a systemd service.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
```
|
||||||
|
apt install samba nfs-kernel-server quota python3 python3-venv
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy files
|
||||||
|
cp -r . /opt/fileshare-web
|
||||||
|
|
||||||
|
# Create virtualenv and install deps
|
||||||
|
python3 -m venv /opt/fileshare-web/venv
|
||||||
|
/opt/fileshare-web/venv/bin/pip install flask gunicorn
|
||||||
|
|
||||||
|
# Create environment file with your credentials
|
||||||
|
cat > /etc/fileshare-web.env <<EOF
|
||||||
|
FILESHARE_USER=admin
|
||||||
|
FILESHARE_PASS=changeme
|
||||||
|
EOF
|
||||||
|
chmod 600 /etc/fileshare-web.env
|
||||||
|
|
||||||
|
# Install and enable the systemd service
|
||||||
|
cp /opt/fileshare-web/fileshare-web.service /etc/systemd/system/
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now fileshare-web
|
||||||
|
```
|
||||||
|
|
||||||
|
The app listens on `127.0.0.1:5000`. Put a reverse proxy (nginx) in front if you want HTTPS, or reach it directly on the LAN by binding to `0.0.0.0:5000` (change `-b` in the unit file).
|
||||||
|
|
||||||
|
## Dev / test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FILESHARE_USER=admin FILESHARE_PASS=secret flask --app app run --debug
|
||||||
|
# or with docker-compose for a sandbox:
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
Set `FILESHARE_USER` and `FILESHARE_PASS` in the environment (or `EnvironmentFile`). All routes require HTTP Basic Auth. Never expose this to the internet — LAN or Tailscale only.
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import functools
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Flask, request, jsonify, send_file, Response
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
VALID_NAME = re.compile(r'^[A-Za-z0-9_-]+$')
|
||||||
|
VALID_NFS_CLIENT = re.compile(r'^[A-Za-z0-9.*:/\-]+$')
|
||||||
|
ALLOWED_PATH_BASE = Path('/srv').resolve()
|
||||||
|
|
||||||
|
_AUTH_USER = os.environ.get('FILESHARE_USER', 'admin')
|
||||||
|
_AUTH_PASS = os.environ.get('FILESHARE_PASS', '')
|
||||||
|
|
||||||
|
if not _AUTH_PASS:
|
||||||
|
import warnings
|
||||||
|
warnings.warn(
|
||||||
|
'FILESHARE_PASS is not set — all routes are unprotected! '
|
||||||
|
'Set it via the environment or /etc/fileshare-web.env.',
|
||||||
|
stacklevel=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_auth(f):
|
||||||
|
@functools.wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
if not _AUTH_PASS:
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
auth = request.authorization
|
||||||
|
if not auth or auth.username != _AUTH_USER or auth.password != _AUTH_PASS:
|
||||||
|
return Response(
|
||||||
|
'Unauthorized', 401,
|
||||||
|
{'WWW-Authenticate': 'Basic realm="fileshare"'},
|
||||||
|
)
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
def validate_path(path_str):
|
||||||
|
"""Resolve path and ensure it falls under /srv."""
|
||||||
|
try:
|
||||||
|
p = Path(path_str).resolve()
|
||||||
|
except Exception:
|
||||||
|
raise ValueError('invalid path')
|
||||||
|
allowed = str(ALLOWED_PATH_BASE)
|
||||||
|
if not (str(p) == allowed or str(p).startswith(allowed + '/')):
|
||||||
|
raise ValueError('path must be under /srv')
|
||||||
|
return str(p)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== SMB =====
|
||||||
|
|
||||||
|
_SMB_SKIP = {'global', 'homes', 'printers', 'print$', 'ipc$'}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_smb():
|
||||||
|
result = subprocess.run(['testparm', '-s'], capture_output=True, text=True)
|
||||||
|
shares = []
|
||||||
|
current = None
|
||||||
|
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
line = line.rstrip()
|
||||||
|
m = re.match(r'^\[(.+)\]$', line)
|
||||||
|
if m:
|
||||||
|
if current is not None and current['name'].lower() not in _SMB_SKIP:
|
||||||
|
shares.append(current)
|
||||||
|
current = {'name': m.group(1), 'path': '', 'valid_users': ''}
|
||||||
|
elif current is not None and '=' in line:
|
||||||
|
key, _, val = line.partition('=')
|
||||||
|
key = key.strip().lower().replace(' ', '_')
|
||||||
|
val = val.strip()
|
||||||
|
if key == 'path':
|
||||||
|
current['path'] = val
|
||||||
|
elif key == 'valid_users':
|
||||||
|
current['valid_users'] = val
|
||||||
|
|
||||||
|
if current is not None and current['name'].lower() not in _SMB_SKIP:
|
||||||
|
shares.append(current)
|
||||||
|
|
||||||
|
return shares
|
||||||
|
|
||||||
|
|
||||||
|
def add_smb_share(name, path, valid_users):
|
||||||
|
config = (
|
||||||
|
f'\n[{name}]\n'
|
||||||
|
f' path = {path}\n'
|
||||||
|
f' valid users = {valid_users}\n'
|
||||||
|
' writable = yes\n'
|
||||||
|
' browseable = yes\n'
|
||||||
|
)
|
||||||
|
with open('/etc/samba/smb.conf', 'a') as f:
|
||||||
|
f.write(config)
|
||||||
|
subprocess.run(['smbcontrol', 'all', 'reload-config'], check=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== NFS =====
|
||||||
|
|
||||||
|
def get_nfs_shares():
|
||||||
|
shares = []
|
||||||
|
try:
|
||||||
|
with open('/etc/exports', 'r') as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith('#'):
|
||||||
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
shares.append({
|
||||||
|
'path': parts[0],
|
||||||
|
'clients': ' '.join(parts[1:]) if len(parts) > 1 else '*',
|
||||||
|
})
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return shares
|
||||||
|
|
||||||
|
|
||||||
|
def add_nfs_share(path, clients, ro=False):
|
||||||
|
options = 'ro' if ro else 'rw'
|
||||||
|
export_line = f'{path}\t{clients}({options},sync,no_subtree_check)\n'
|
||||||
|
with open('/etc/exports', 'a') as f:
|
||||||
|
f.write(export_line)
|
||||||
|
subprocess.run(['exportfs', '-ra'], check=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== ROUTES =====
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
@require_auth
|
||||||
|
def index():
|
||||||
|
return send_file(os.path.join(os.path.dirname(__file__), 'index.html'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/shares', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def get_all_shares():
|
||||||
|
return jsonify({'smb': parse_smb(), 'nfs': get_nfs_shares()})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/share/smb', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def create_smb():
|
||||||
|
data = request.json or {}
|
||||||
|
name = data.get('name', '')
|
||||||
|
path = data.get('path', '')
|
||||||
|
valid_users = data.get('validUsers', '')
|
||||||
|
|
||||||
|
if not VALID_NAME.match(name):
|
||||||
|
return jsonify({'error': 'invalid share name (letters, digits, _ -)'}), 400
|
||||||
|
users = []
|
||||||
|
for u in re.split(r'[\s,]+', valid_users):
|
||||||
|
if not u:
|
||||||
|
continue
|
||||||
|
if not VALID_NAME.match(u):
|
||||||
|
return jsonify({'error': f'invalid username: {u}'}), 400
|
||||||
|
users.append(u)
|
||||||
|
try:
|
||||||
|
safe_path = validate_path(path)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'error': str(e)}), 400
|
||||||
|
|
||||||
|
add_smb_share(name, safe_path, ', '.join(users))
|
||||||
|
return jsonify({'type': 'smb', 'status': 'created'})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/share/nfs', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def create_nfs():
|
||||||
|
data = request.json or {}
|
||||||
|
path = data.get('path', '')
|
||||||
|
clients = data.get('clients', '*')
|
||||||
|
ro = data.get('readOnly', False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
safe_path = validate_path(path)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'error': str(e)}), 400
|
||||||
|
if not VALID_NFS_CLIENT.match(clients):
|
||||||
|
return jsonify({'error': 'invalid client spec'}), 400
|
||||||
|
|
||||||
|
add_nfs_share(safe_path, clients, ro)
|
||||||
|
return jsonify({'type': 'nfs', 'status': 'created'})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/quota', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def set_quota():
|
||||||
|
data = request.json or {}
|
||||||
|
user = data.get('user', '')
|
||||||
|
quota_type = data.get('type', 'smb')
|
||||||
|
raw_quota = data.get('quota', 0)
|
||||||
|
path = data.get('path', '/srv/samba')
|
||||||
|
|
||||||
|
if not VALID_NAME.match(user):
|
||||||
|
return jsonify({'error': 'invalid username'}), 400
|
||||||
|
try:
|
||||||
|
quota_gb = int(raw_quota)
|
||||||
|
if quota_gb <= 0:
|
||||||
|
raise ValueError()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({'error': 'quota must be a positive integer (GB)'}), 400
|
||||||
|
|
||||||
|
if quota_type == 'smb':
|
||||||
|
mount = '/srv/samba'
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
mount = validate_path(path)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'error': str(e)}), 400
|
||||||
|
|
||||||
|
# setquota takes kilobytes; soft = requested, hard = +1 GB buffer
|
||||||
|
soft_kb = str(quota_gb * 1024 * 1024)
|
||||||
|
hard_kb = str((quota_gb + 1) * 1024 * 1024)
|
||||||
|
subprocess.run(
|
||||||
|
['setquota', '-u', user, soft_kb, hard_kb, '0', '0', mount],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(host='0.0.0.0', port=5000, debug=False)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
share-manager:
|
||||||
|
image: python:3.11-slim
|
||||||
|
volumes:
|
||||||
|
- ./app.py:/app/app.py
|
||||||
|
- /etc/samba:/etc/samba
|
||||||
|
- /etc/exports:/etc/exports
|
||||||
|
- /srv:/srv
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
working_dir: /app
|
||||||
|
command: >
|
||||||
|
sh -c "pip install flask &&
|
||||||
|
python app.py"
|
||||||
|
cap_add:
|
||||||
|
- SYS_ADMIN
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Fileshare Web GUI
|
||||||
|
After=network.target smbd.service nfs-server.service
|
||||||
|
Wants=smbd.service nfs-server.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/opt/fileshare-web
|
||||||
|
EnvironmentFile=/etc/fileshare-web.env
|
||||||
|
ExecStart=/opt/fileshare-web/venv/bin/gunicorn -w 1 -b 127.0.0.1:5000 app:app
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
+223
@@ -0,0 +1,223 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>SMB + NFS Manager</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5/dist/css/bootstrap.min.css">
|
||||||
|
</head>
|
||||||
|
<body class="bg-dark text-white p-4">
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="mb-4">📁 Share Manager (SMB + NFS)</h1>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<ul class="nav nav-tabs" role="tablist">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" data-bs-toggle="tab" href="#smb">SMB Shares</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#nfs">NFS Exports</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#quotas">Quotas</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- SMB TAB -->
|
||||||
|
<div id="smb" class="tab-pane fade show active mt-4">
|
||||||
|
<h3>SMB Shares</h3>
|
||||||
|
|
||||||
|
<form class="row g-3 mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<input type="text" id="smbName" class="form-control" placeholder="Share Name" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<input type="text" id="smbPath" class="form-control" placeholder="/srv/samba/..." required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<input type="text" id="smbUsers" class="form-control" placeholder="user1, user2">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button type="button" class="btn btn-success w-100" onclick="addSMBShare()">
|
||||||
|
Add SMB
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="smbList" class="table-responsive">
|
||||||
|
<table class="table table-dark table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Path</th>
|
||||||
|
<th>Valid Users</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="smbTableBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NFS TAB -->
|
||||||
|
<div id="nfs" class="tab-pane fade mt-4">
|
||||||
|
<h3>NFS Exports</h3>
|
||||||
|
|
||||||
|
<form class="row g-3 mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<input type="text" id="nfsPath" class="form-control" placeholder="/srv/data" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<input type="text" id="nfsClients" class="form-control" placeholder="192.168.1.0/24 or *">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-check">
|
||||||
|
<input type="checkbox" id="nfsReadOnly" class="form-check-input">
|
||||||
|
Read Only
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button type="button" class="btn btn-success w-100" onclick="addNFSShare()">
|
||||||
|
Add NFS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="nfsList" class="table-responsive">
|
||||||
|
<table class="table table-dark table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Path</th>
|
||||||
|
<th>Clients</th>
|
||||||
|
<th>Options</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="nfsTableBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- QUOTAS TAB -->
|
||||||
|
<div id="quotas" class="tab-pane fade mt-4">
|
||||||
|
<h3>Set Quotas</h3>
|
||||||
|
|
||||||
|
<form class="row g-3 mb-4">
|
||||||
|
<div class="col-md-2">
|
||||||
|
<select id="quotaType" class="form-select">
|
||||||
|
<option value="smb">SMB</option>
|
||||||
|
<option value="nfs">NFS</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<input type="text" id="quotaUser" class="form-control" placeholder="Username">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<input type="text" id="quotaPath" class="form-control" placeholder="/srv/data (NFS only)">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<input type="number" id="quotaGB" class="form-control" placeholder="Quota (GB)">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<button type="button" class="btn btn-warning w-100" onclick="setQuota()">
|
||||||
|
Set Quota
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function addSMBShare() {
|
||||||
|
const data = {
|
||||||
|
name: document.getElementById('smbName').value,
|
||||||
|
path: document.getElementById('smbPath').value,
|
||||||
|
validUsers: document.getElementById('smbUsers').value
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch('/api/share/smb', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
alert('SMB Share created!');
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addNFSShare() {
|
||||||
|
const data = {
|
||||||
|
path: document.getElementById('nfsPath').value,
|
||||||
|
clients: document.getElementById('nfsClients').value,
|
||||||
|
readOnly: document.getElementById('nfsReadOnly').checked
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch('/api/share/nfs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
alert('NFS Export created!');
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setQuota() {
|
||||||
|
const data = {
|
||||||
|
type: document.getElementById('quotaType').value,
|
||||||
|
user: document.getElementById('quotaUser').value,
|
||||||
|
path: document.getElementById('quotaPath').value,
|
||||||
|
quota: document.getElementById('quotaGB').value
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch('/api/quota', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
alert(res.ok ? 'Quota set!' : 'Error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load shares on startup
|
||||||
|
async function loadShares() {
|
||||||
|
const res = await fetch('/api/shares');
|
||||||
|
const shares = await res.json();
|
||||||
|
|
||||||
|
// Populate SMB table
|
||||||
|
const smbBody = document.getElementById('smbTableBody');
|
||||||
|
shares.smb.forEach(share => {
|
||||||
|
smbBody.innerHTML += `
|
||||||
|
<tr>
|
||||||
|
<td>${share.name}</td>
|
||||||
|
<td>${share.path}</td>
|
||||||
|
<td>${share.valid_users}</td>
|
||||||
|
<td><button class="btn btn-sm btn-danger">Delete</button></td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Populate NFS table
|
||||||
|
const nfsBody = document.getElementById('nfsTableBody');
|
||||||
|
shares.nfs.forEach(share => {
|
||||||
|
nfsBody.innerHTML += `
|
||||||
|
<tr>
|
||||||
|
<td>${share.path}</td>
|
||||||
|
<td>${share.clients}</td>
|
||||||
|
<td>-</td>
|
||||||
|
<td><button class="btn btn-sm btn-danger">Delete</button></td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadShares();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in new issue
Block a user