feat: add Demo Service — Caddy + FastAPI for hosting demo sites at demo.junv.cc

- Caddy as static file server + reverse proxy
- FastAPI management API with Pocket ID SSO
- Web UI at demo.junv.cc/admin
- Agent API with Bearer token auth
- Demo sites accessible at demo.junv.cc/<folder-name>
- NFS PVC for persistent storage
- Enable/disable per-site
This commit is contained in:
Junv (via Hermes)
2026-06-14 10:57:36 +10:00
parent 3a860bb486
commit d4484e6750
4 changed files with 852 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
# Caddy for Demo Service — internal server, TLS handled by Nginx Ingress
# Auth is handled by the Python API (calls OAuth2 Proxy for SSO validation)
:80 {
log {
output stdout
format json
}
# Management API → Python service
handle /api/* {
reverse_proxy localhost:3000 {
header_up Host {host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto https
}
}
# Admin Web UI → Python service
handle /admin/* {
reverse_proxy localhost:3000
}
# Root → redirect to admin
handle / {
reverse_proxy localhost:3000
}
# Static demo sites — public, no auth
handle_path /* {
root * /data/demos
file_server {
index index.html
}
}
header {
X-Content-Type-Options nosniff
-Server
}
}
+16
View File
@@ -0,0 +1,16 @@
FROM python:3.12-slim
WORKDIR /app
# Install dependencies (added httpx for OAuth2 proxy cookie validation)
RUN pip install --no-cache-dir fastapi uvicorn python-multipart httpx
# Copy the service
COPY service.py /app/
# Create data directory
RUN mkdir -p /data/demos
EXPOSE 3000
CMD ["uvicorn", "service:app", "--host", "0.0.0.0", "--port", "3000", "--log-level", "info"]
+174
View File
@@ -0,0 +1,174 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: demo-sites-pvc
namespace: home-apps
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
storageClassName: nfs-client
---
apiVersion: v1
kind: ConfigMap
metadata:
name: caddy-config
namespace: home-apps
data:
Caddyfile: |
# Caddy for Demo Service — internal server, TLS handled by Nginx Ingress
# Auth is handled by the Python API (calls OAuth2 Proxy for SSO validation)
:80 {
log {
output stdout
format json
}
# Management API → Python service
handle /api/* {
reverse_proxy localhost:3000 {
header_up Host {host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto https
}
}
# Admin Web UI → Python service
handle /admin/* {
reverse_proxy localhost:3000
}
# Root → redirect to admin
handle / {
reverse_proxy localhost:3000
}
# Static demo sites — public, no auth
handle_path /* {
root * /data/demos
file_server {
index index.html
}
}
header {
X-Content-Type-Options nosniff
-Server
}
}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: demo-service
namespace: home-apps
labels:
app: demo-service
spec:
replicas: 1
selector:
matchLabels:
app: demo-service
template:
metadata:
labels:
app: demo-service
spec:
containers:
# ── Caddy — static file server + reverse proxy ──
- name: caddy
image: mirror.gcr.io/caddy:2
ports:
- containerPort: 80
name: http
volumeMounts:
- name: caddy-config
mountPath: /etc/caddy/Caddyfile
subPath: Caddyfile
- name: demo-data
mountPath: /data/demos
resources:
requests:
memory: 32Mi
cpu: 50m
limits:
memory: 128Mi
cpu: 200m
# ── Demo Manager API ──
- name: api
image: docker.io/library/demo-manager:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 3000
name: api
env:
- name: DEMO_ROOT
value: /data/demos
- name: DEMO_API_KEY
value: hermes-demo-secret-key-change-me
- name: OAUTH_PROXY_URL
value: https://pass.junv.cc
volumeMounts:
- name: demo-data
mountPath: /data/demos
resources:
requests:
memory: 64Mi
cpu: 100m
limits:
memory: 256Mi
cpu: 500m
volumes:
- name: caddy-config
configMap:
name: caddy-config
- name: demo-data
persistentVolumeClaim:
claimName: demo-sites-pvc
---
apiVersion: v1
kind: Service
metadata:
name: demo-service
namespace: home-apps
spec:
selector:
app: demo-service
ports:
- port: 80
targetPort: 80
name: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: demo-service-ingress
namespace: home-apps
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
kubernetes.io/tls-acme: "true"
# No global auth — public demo sites are served without auth
# API and Admin auth is handled by the Python service
# (validates Pocket ID session via OAuth2 Proxy internally)
spec:
ingressClassName: nginx
tls:
- hosts:
- demo.junv.cc
secretName: demo-service-tls
rules:
- host: demo.junv.cc
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: demo-service
port:
number: 80
+621
View File
@@ -0,0 +1,621 @@
#!/usr/bin/env python3
"""
Demo Manager — FastAPI service for managing demo sites.
Pocket ID SSO auth via X-Auth-Request-Email header (set by OAuth2 proxy).
"""
import json
import os
import shutil
import uuid
import httpx
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, Request, HTTPException, UploadFile, File, Form, Response
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
DEMO_ROOT = Path(os.environ.get("DEMO_ROOT", "/data/demos"))
META_FILE = DEMO_ROOT / ".meta" / "sites.json"
API_KEY = os.environ.get("DEMO_API_KEY", "hermes-demo-secret-key-change-me")
OAUTH_PROXY = os.environ.get("OAUTH_PROXY_URL", "https://pass.junv.cc")
app = FastAPI(title="Demo Manager", version="1.0.0")
# ─── Models ─────────────────────────────────────────
class SiteInfo(BaseModel):
name: str
enabled: bool = True
description: str = ""
created_at: str = ""
updated_at: str = ""
class SiteCreate(BaseModel):
name: str
description: str = ""
# ─── Helpers ────────────────────────────────────────
def load_meta() -> dict:
META_FILE.parent.mkdir(parents=True, exist_ok=True)
if META_FILE.exists():
return json.loads(META_FILE.read_text())
return {"sites": {}}
def save_meta(meta: dict):
META_FILE.parent.mkdir(parents=True, exist_ok=True)
META_FILE.write_text(json.dumps(meta, indent=2))
def check_auth(request: Request) -> Optional[str]:
"""Return user email if authenticated, None otherwise."""
# API key auth (for agent access)
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
if token == API_KEY:
return "api-agent"
# OAuth2 proxy header (for browser SSO — set when using Nginx auth annotations)
email = request.headers.get("X-Auth-Request-Email") or request.headers.get("X-Forwarded-Email")
if email:
return email
# Server-side cookie validation via OAuth2 Proxy
# The _oauth2_proxy cookie is scoped to .junv.cc, so it's sent to demo.junv.cc
oauth_cookie = request.cookies.get("_oauth2_proxy")
if oauth_cookie:
try:
# Validate session with OAuth2 Proxy
resp = httpx.get(
f"{OAUTH_PROXY}/oauth2/auth",
cookies={"_oauth2_proxy": oauth_cookie},
headers={"X-Auth-Request-Redirect": str(request.url)},
timeout=5,
)
if resp.status_code == 202:
# Valid session — user is authenticated
user_email = resp.headers.get("X-Auth-Request-Email", "")
if user_email:
return user_email
except Exception:
pass # Fail open → fall through to None
return None
async def check_auth_async(request: Request) -> Optional[str]:
"""Async wrapper for check_auth."""
return check_auth(request)
def require_auth(request: Request) -> str:
user = check_auth(request)
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
return user
def site_exists(name: str) -> bool:
"""Check if site exists: meta entry must exist + directory should exist.
If meta entry doesn't exist, site is considered deleted even if directory lingers (NFS stale handles)."""
meta = load_meta()
if name not in meta.get("sites", {}):
return False
# Meta says it exists — verify directory too
site_dir = DEMO_ROOT / name
try:
return site_dir.is_dir()
except OSError:
return True # NFS stale handle, trust meta
def site_index_path(name: str) -> bool:
"""Check if the site has an index.html."""
return (DEMO_ROOT / name / "index.html").exists()
def ensure_disabled_index(name: str):
"""Copy index.html to .index.html.bak and remove index.html."""
site_dir = DEMO_ROOT / name
site_dir.mkdir(parents=True, exist_ok=True)
real_index = site_dir / "index.html"
backup_index = site_dir / ".index.html.bak"
if real_index.exists():
# Copy content then remove original (avoids NFS rename issues)
backup_index.write_bytes(real_index.read_bytes())
real_index.unlink()
def ensure_enabled_index(name: str):
"""Restore index.html from .index.html.bak."""
site_dir = DEMO_ROOT / name
backup_index = site_dir / ".index.html.bak"
real_index = site_dir / "index.html"
if backup_index.exists():
real_index.write_bytes(backup_index.read_bytes())
backup_index.unlink()
# ─── API Routes ─────────────────────────────────────
@app.get("/api/health")
def health():
return {"status": "ok", "root": str(DEMO_ROOT)}
@app.get("/api/sites")
def list_sites(request: Request):
user = require_auth(request)
meta = load_meta()
sites = []
for name in sorted(meta.get("sites", {}).keys()):
info = meta["sites"][name]
site_dir = DEMO_ROOT / name
file_count = 0
if site_dir.is_dir():
file_count = sum(1 for _ in site_dir.rglob("*") if _.is_file())
sites.append({**info, "name": name, "exists": site_dir.is_dir(), "file_count": file_count})
return {"sites": sites, "user": user}
@app.post("/api/sites")
def create_site(body: SiteCreate, request: Request):
user = require_auth(request)
name = body.name.strip().lower().replace(" ", "-")
if not name:
raise HTTPException(status_code=400, detail="Name is required")
if site_exists(name):
raise HTTPException(status_code=409, detail=f"Site '{name}' already exists")
now = datetime.now(timezone.utc).isoformat()
meta = load_meta()
if "sites" not in meta:
meta["sites"] = {}
site_dir = DEMO_ROOT / name
site_dir.mkdir(parents=True, exist_ok=True)
# Create a default index.html
(site_dir / "index.html").write_text(f"""<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>{name}</title>
<style>body{{font-family:sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f5f5f5}}h1{{color:#333}}</style>
</head>
<body><h1>🚀 {name}</h1></body>
</html>""")
site_info = {
"name": name,
"enabled": True,
"description": body.description,
"created_at": now,
"updated_at": now,
}
meta["sites"][name] = site_info
save_meta(meta)
return site_info
@app.delete("/api/sites/{name}")
def delete_site(name: str, request: Request):
user = require_auth(request)
if not site_exists(name):
raise HTTPException(status_code=404, detail=f"Site '{name}' not found")
# Always remove from meta first (handles NFS stale file issues)
meta = load_meta()
meta["sites"].pop(name, None)
save_meta(meta)
# Try to remove directory; handle NFS issues gracefully
site_dir = DEMO_ROOT / name
try:
shutil.rmtree(site_dir)
except OSError:
pass # NFS stale file handle — directory will be orphaned but meta is clean
return {"status": "deleted", "name": name}
@app.post("/api/sites/{name}/enable")
def enable_site(name: str, request: Request):
user = require_auth(request)
if not site_exists(name):
raise HTTPException(status_code=404, detail=f"Site '{name}' not found")
ensure_enabled_index(name)
meta = load_meta()
if name in meta.get("sites", {}):
meta["sites"][name]["enabled"] = True
meta["sites"][name]["updated_at"] = datetime.now(timezone.utc).isoformat()
save_meta(meta)
return {"status": "enabled", "name": name}
@app.post("/api/sites/{name}/disable")
def disable_site(name: str, request: Request):
user = require_auth(request)
if not site_exists(name):
raise HTTPException(status_code=404, detail=f"Site '{name}' not found")
ensure_disabled_index(name)
meta = load_meta()
if name in meta.get("sites", {}):
meta["sites"][name]["enabled"] = False
meta["sites"][name]["updated_at"] = datetime.now(timezone.utc).isoformat()
save_meta(meta)
return {"status": "disabled", "name": name}
@app.get("/api/sites/{name}")
def get_site(name: str, request: Request):
require_auth(request)
if not site_exists(name):
raise HTTPException(status_code=404, detail=f"Site '{name}' not found")
meta = load_meta()
info = meta.get("sites", {}).get(name, {"name": name, "enabled": True})
site_dir = DEMO_ROOT / name
files = []
if site_dir.is_dir():
for f in sorted(site_dir.rglob("*")):
if f.is_file() and not f.name.startswith("."):
rel = str(f.relative_to(site_dir))
files.append({"path": rel, "size": f.stat().st_size})
return {"site": info, "files": files, "url": f"https://demo.junv.cc/{name}"}
@app.post("/api/sites/{name}/files")
async def upload_file(name: str, request: Request, file: UploadFile = File(...), path: str = Form("")):
"""Upload a file to a demo site. Set path to put file in a subdirectory."""
user = require_auth(request)
if not site_exists(name):
raise HTTPException(status_code=404, detail=f"Site '{name}' not found. Create it first.")
site_dir = DEMO_ROOT / name
# Prevent path traversal
safe_path = path.strip("/").replace("..", "")
dest_dir = site_dir / safe_path
dest_dir.mkdir(parents=True, exist_ok=True)
file_path = dest_dir / file.filename
content = await file.read()
file_path.write_bytes(content)
# Update timestamp
meta = load_meta()
if name in meta.get("sites", {}):
meta["sites"][name]["updated_at"] = datetime.now(timezone.utc).isoformat()
save_meta(meta)
return {
"status": "uploaded",
"name": name,
"file": str(file_path.relative_to(site_dir)),
"size": len(content),
"url": f"https://demo.junv.cc/{name}/{str(file_path.relative_to(site_dir))}"
}
@app.delete("/api/sites/{name}/files")
def delete_file(name: str, request: Request, path: str = Form("")):
"""Delete a file from a demo site."""
user = require_auth(request)
if not site_exists(name):
raise HTTPException(status_code=404, detail=f"Site '{name}' not found")
safe_path = path.strip("/").replace("..", "")
if not safe_path:
raise HTTPException(status_code=400, detail="path is required")
file_path = DEMO_ROOT / name / safe_path
if not file_path.exists():
raise HTTPException(status_code=404, detail=f"File not found: {safe_path}")
if file_path.is_dir():
shutil.rmtree(file_path)
else:
file_path.unlink()
return {"status": "deleted", "name": name, "file": safe_path}
# ─── Web UI ─────────────────────────────────────────
UI_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Demo Manager — junv.cc</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0d1117;color:#c9d1d9;min-height:100vh}
.header{background:#161b22;border-bottom:1px solid #30363d;padding:16px 24px;display:flex;justify-content:space-between;align-items:center}
.header h1{font-size:20px;color:#f0f6fc}
.header .user{font-size:13px;color:#8b949e}
.container{max-width:1000px;margin:24px auto;padding:0 24px}
.section{margin-bottom:32px}
.section h2{font-size:18px;color:#f0f6fc;margin-bottom:12px}
.btn{padding:8px 16px;border-radius:6px;border:1px solid #30363d;background:#21262d;color:#c9d1d9;cursor:pointer;font-size:13px;transition:all .15s}
.btn:hover{background:#30363d}
.btn-primary{background:#1f6feb;border-color:#1f6feb;color:#fff}
.btn-primary:hover{background:#388bfd}
.btn-danger{background:#da3633;border-color:#da3633;color:#fff}
.btn-danger:hover{background:#f85149}
.btn-sm{padding:4px 10px;font-size:12px}
.site-card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;margin-bottom:12px;display:flex;justify-content:space-between;align-items:center}
.site-card .info{flex:1}
.site-card .name{font-size:16px;font-weight:600;color:#58a6ff}
.site-card .name a{color:inherit;text-decoration:none}
.site-card .name a:hover{text-decoration:underline}
.site-card .desc{font-size:13px;color:#8b949e;margin-top:4px}
.site-card .meta{font-size:11px;color:#6e7681;margin-top:4px}
.site-card .actions{display:flex;gap:8px}
.badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600}
.badge-on{background:#1b3a1b;color:#3fb950}
.badge-off{background:#3a1b1b;color:#f85149}
.form-group{margin-bottom:12px}
.form-group label{display:block;font-size:13px;color:#8b949e;margin-bottom:4px}
.form-group input{width:100%;padding:8px 12px;border-radius:6px;border:1px solid #30363d;background:#0d1117;color:#c9d1d9;font-size:14px}
.form-group input:focus{outline:none;border-color:#1f6feb}
.form-row{display:flex;gap:12px;align-items:end}
.file-list{list-style:none}
.file-list li{display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px solid #21262d;font-size:13px}
.file-list li:last-child{border-bottom:none}
.upload-zone{border:2px dashed #30363d;border-radius:8px;padding:24px;text-align:center;cursor:pointer;transition:all .15s}
.upload-zone:hover{border-color:#58a6ff;background:#161b22}
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:100}
.modal{background:#161b22;border:1px solid #30363d;border-radius:12px;padding:24px;max-width:500px;width:90%}
.modal h3{color:#f0f6fc;margin-bottom:16px}
.toast{position:fixed;bottom:24px;right:24px;padding:12px 20px;border-radius:8px;font-size:14px;z-index:200;animation:slideIn .3s}
.toast-success{background:#1b3a1b;border:1px solid #3fb950;color:#3fb950}
.toast-error{background:#3a1b1b;border:1px solid #f85149;color:#f85149}
@keyframes slideIn{from{transform:translateY(20px);opacity:0}to{transform:translateY(0);opacity:1}}
.spinner{display:inline-block;width:16px;height:16px;border:2px solid #30363d;border-top-color:#58a6ff;border-radius:50%;animation:spin .6s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
</style>
</head>
<body>
<div class="header">
<h1>🧪 Demo Manager</h1>
<div class="user" id="user-info">Loading...</div>
</div>
<div class="container">
<div class="section">
<h2> Create Demo Site</h2>
<form id="create-form" class="form-row">
<div class="form-group" style="flex:2">
<label>Site Name</label>
<input type="text" id="site-name" placeholder="my-cool-demo" pattern="[a-z0-9-]+" required>
</div>
<div class="form-group" style="flex:3">
<label>Description (optional)</label>
<input type="text" id="site-desc" placeholder="A quick demo of...">
</div>
<button type="submit" class="btn btn-primary" style="height:38px">Create</button>
</form>
</div>
<div class="section">
<h2>📂 My Demo Sites</h2>
<div id="sites-list">Loading...</div>
</div>
</div>
<div id="file-modal" class="modal-overlay" style="display:none">
<div class="modal" id="file-modal-content"></div>
</div>
<div id="toast-container"></div>
<script>
let currentUser = '';
const API = '/api/sites';
async function api(method, path, body) {
const opts = { method, headers: {'Content-Type': 'application/json'} };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(path, opts);
if (!res.ok) {
const err = await res.json().catch(() => ({detail: res.statusText}));
throw new Error(err.detail || 'Request failed');
}
return res.json();
}
async function uploadFile(siteName, file, subPath) {
const form = new FormData();
form.append('file', file);
form.append('path', subPath || '');
const res = await fetch(`${API}/${siteName}/files`, { method: 'POST', body: form });
if (!res.ok) {
const err = await res.json().catch(() => ({detail: res.statusText}));
throw new Error(err.detail || 'Upload failed');
}
return res.json();
}
function toast(msg, type) {
const el = document.createElement('div');
el.className = `toast toast-${type}`;
el.textContent = msg;
document.getElementById('toast-container').appendChild(el);
setTimeout(() => el.remove(), 3000);
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB';
}
function formatDate(iso) {
if (!iso) return '-';
return new Date(iso).toLocaleString();
}
async function loadSites() {
const container = document.getElementById('sites-list');
try {
const data = await api('GET', API);
currentUser = data.user;
document.getElementById('user-info').textContent = '👤 ' + currentUser + ' (via Pocket ID)';
if (data.sites.length === 0) {
container.innerHTML = '<p style="color:#8b949e;text-align:center;padding:32px">No demo sites yet. Create one above!</p>';
return;
}
container.innerHTML = data.sites.map(s => `
<div class="site-card">
<div class="info">
<div class="name">
<a href="https://demo.junv.cc/${s.name}" target="_blank">${s.name}</a>
<span class="badge ${s.enabled ? 'badge-on' : 'badge-off'}">${s.enabled ? 'ON' : 'OFF'}</span>
</div>
${s.description ? `<div class="desc">${s.description}</div>` : ''}
<div class="meta">${s.file_count} files · created ${formatDate(s.created_at)}</div>
</div>
<div class="actions">
<button class="btn btn-sm" onclick="manageSite('${s.name}')">📁 Files</button>
${s.enabled
? `<button class="btn btn-sm btn-danger" onclick="toggleSite('${s.name}','disable')">⏸ Disable</button>`
: `<button class="btn btn-sm" onclick="toggleSite('${s.name}','enable')" style="background:#1b3a1b;border-color:#3fb950;color:#3fb950">▶ Enable</button>`
}
<button class="btn btn-sm btn-danger" onclick="deleteSite('${s.name}')">🗑 Delete</button>
</div>
</div>
`).join('');
} catch (e) {
container.innerHTML = `<p style="color:#f85149">Error loading sites: ${e.message}</p>`;
}
}
async function createSite(e) {
e.preventDefault();
const name = document.getElementById('site-name').value.trim();
const desc = document.getElementById('site-desc').value.trim();
if (!name) return;
try {
await api('POST', API, { name, description: desc });
document.getElementById('site-name').value = '';
document.getElementById('site-desc').value = '';
toast(`Site "${name}" created!`, 'success');
loadSites();
} catch (e) {
toast(e.message, 'error');
}
}
async function toggleSite(name, action) {
try {
await api('POST', `${API}/${name}/${action}`);
toast(`Site "${name}" ${action}d`, 'success');
loadSites();
} catch (e) {
toast(e.message, 'error');
}
}
async function deleteSite(name) {
if (!confirm(`Delete "${name}"? This cannot be undone.`)) return;
try {
await api('DELETE', `${API}/${name}`);
toast(`Site "${name}" deleted`, 'success');
loadSites();
} catch (e) {
toast(e.message, 'error');
}
}
async function manageSite(name) {
const modal = document.getElementById('file-modal');
const content = document.getElementById('file-modal-content');
modal.style.display = 'flex';
content.innerHTML = `
<h3>📁 ${name}</h3>
<div style="margin-bottom:16px">
<a href="https://demo.junv.cc/${name}" target="_blank" style="color:#58a6ff;text-decoration:none">🔗 demo.junv.cc/${name}</a>
</div>
<div class="upload-zone" id="upload-zone-${name}">
<div id="upload-label-${name}">📤 Drop files here or click to upload</div>
<input type="file" id="file-input-${name}" style="display:none" multiple>
<div id="upload-progress-${name}" style="display:none;margin-top:8px"></div>
</div>
<div style="margin:16px 0"><strong style="font-size:13px;color:#8b949e">Files:</strong></div>
<div id="files-list-${name}" style="max-height:300px;overflow-y:auto">Loading...</div>
<div style="margin-top:16px;text-align:right">
<button class="btn" onclick="document.getElementById('file-modal').style.display='none'">Close</button>
</div>
`;
// Load files
try {
const data = await api('GET', `${API}/${name}`);
const filesDiv = document.getElementById(`files-list-${name}`);
if (data.files.length === 0) {
filesDiv.innerHTML = '<p style="color:#8b949e">No files yet.</p>';
} else {
filesDiv.innerHTML = '<ul class="file-list">' + data.files.map(f => `
<li>
<span>
<a href="https://demo.junv.cc/${name}/${f.path}" target="_blank" style="color:#58a6ff">${f.path}</a>
</span>
<span style="color:#8b949e">
${formatSize(f.size)}
<button class="btn btn-sm btn-danger" style="margin-left:8px" onclick="deleteFile('${name}','${f.path}')">×</button>
</span>
</li>
`).join('') + '</ul>';
}
} catch (e) {
document.getElementById(`files-list-${name}`).innerHTML = `<p style="color:#f85149">${e.message}</p>`;
}
// Upload handler
const zone = document.getElementById(`upload-zone-${name}`);
const input = document.getElementById(`file-input-${name}`);
zone.onclick = () => input.click();
zone.ondragover = (e) => { e.preventDefault(); zone.style.borderColor = '#58a6ff'; };
zone.ondragleave = () => { zone.style.borderColor = ''; };
zone.ondrop = async (e) => {
e.preventDefault();
zone.style.borderColor = '';
const files = e.dataTransfer.files;
await handleUpload(name, files);
};
input.onchange = async () => {
await handleUpload(name, input.files);
input.value = '';
};
}
async function handleUpload(siteName, files) {
const progressDiv = document.getElementById(`upload-progress-${siteName}`);
const label = document.getElementById(`upload-label-${siteName}`);
progressDiv.style.display = 'block';
for (const file of files) {
progressDiv.innerHTML = `<span class="spinner"></span> Uploading ${file.name}...`;
try {
await uploadFile(siteName, file, '');
progressDiv.innerHTML += ` ✅ ${file.name}<br>`;
} catch (e) {
progressDiv.innerHTML += ` ❌ ${file.name}: ${e.message}<br>`;
}
}
label.textContent = '📤 Drop more files or click to upload';
toast('Upload complete!', 'success');
manageSite(siteName); // Refresh
}
async function deleteFile(siteName, filePath) {
if (!confirm(`Delete "${filePath}"?`)) return;
try {
const form = new FormData();
form.append('path', filePath);
const res = await fetch(`${API}/${siteName}/files`, { method: 'DELETE', body: form });
if (!res.ok) throw new Error((await res.json().catch(()=>({detail:'Failed'}))).detail || 'Failed');
toast(`Deleted ${filePath}`, 'success');
manageSite(siteName); // Refresh
} catch (e) {
toast(e.message, 'error');
}
}
document.getElementById('create-form').onsubmit = createSite;
loadSites();
</script>
</body>
</html>"""
@app.get("/admin", response_class=HTMLResponse)
@app.get("/admin/", response_class=HTMLResponse)
@app.get("/admin/{path:path}", response_class=HTMLResponse)
def admin_ui(request: Request, path: str = ""):
"""Serve the admin Web UI."""
require_auth(request)
return HTMLResponse(UI_HTML)
@app.get("/")
def index(request: Request):
"""Root redirects to admin."""
return HTMLResponse("""<!DOCTYPE html>
<html><head><meta http-equiv="refresh" content="0;url=/admin"></head>
<body><p>Redirecting to <a href="/admin">Admin</a>...</p></body>
</html>""")