mirror of
https://github.com/wahyd4/home-docker.git
synced 2026-08-09 04:15:52 +10:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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>""")
|
||||
Reference in New Issue
Block a user