feat(demo-service): add publish/unpublish draft system with preview

- Sites now start as drafts (published: false) — hidden from public
- Publish endpoint makes site live at demo.junv.cc/<name>
- Unpublish hides it back to draft
- Preview endpoint serves draft HTML to authenticated users
- Caddy blocks .draft/.bak files (defense-in-depth)
- Backward compatible: existing sites default to published
- sync_visibility() handles all state transitions
This commit is contained in:
Junv (via Hermes)
2026-06-14 11:10:25 +10:00
parent 3e36501eba
commit 4dce2be3d4
2 changed files with 319 additions and 287 deletions
+9
View File
@@ -29,16 +29,25 @@
reverse_proxy localhost:3000
}
# ── Security: block access to internal/hidden files ──
# Prevent leaking .draft, .bak, .meta, .git files
@blocked path_regexp \.(draft|bak)$|^\.meta|^\.git
handle @blocked {
error 404
}
# Static demo sites — public, no auth
handle_path /* {
root * /data/demos
file_server {
index index.html
hide .*
}
}
header {
X-Content-Type-Options nosniff
X-Frame-Options SAMEORIGIN
-Server
}
}
+310 -287
View File
@@ -1,33 +1,33 @@
#!/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).
Sites start as drafts (unpublished). Publishing makes them public.
Pocket ID SSO + API key auth.
"""
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 fastapi import FastAPI, Request, HTTPException, UploadFile, File, Form
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
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")
API_KEY = os.environ.get("DEMO_API_KEY", "")
OAUTH_PROXY = os.environ.get("OAUTH_PROXY_URL", "https://pass.junv.cc")
app = FastAPI(title="Demo Manager", version="1.0.0")
app = FastAPI(title="Demo Manager", version="1.1.0")
# ─── Models ─────────────────────────────────────────
class SiteInfo(BaseModel):
name: str
enabled: bool = True
published: bool = False # NEW: draft by default
description: str = ""
created_at: str = ""
updated_at: str = ""
@@ -35,6 +35,7 @@ class SiteInfo(BaseModel):
class SiteCreate(BaseModel):
name: str
description: str = ""
published: bool = False # Can opt to publish on create
# ─── Helpers ────────────────────────────────────────
def load_meta() -> dict:
@@ -48,42 +49,30 @@ def save_meta(meta: dict):
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
pass
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:
@@ -91,42 +80,75 @@ def require_auth(request: Request) -> str:
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 is_site_visible(name: str) -> bool:
"""Is the site publicly visible? (enabled AND published)."""
meta = load_meta()
info = meta.get("sites", {}).get(name, {})
return info.get("enabled", True) and info.get("published", False)
def ensure_disabled_index(name: str):
"""Copy index.html to .index.html.bak and remove index.html."""
# ─── File Helpers (no rename — NFS safe) ────────────
def hide_index(name: str, suffix: str = ".bak"):
"""Move index.html out of Caddy's reach."""
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"
backup_index = site_dir / f".index.html{suffix}"
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."""
def restore_index(name: str, suffix: str):
"""Restore index.html from backup."""
site_dir = DEMO_ROOT / name
backup_index = site_dir / ".index.html.bak"
backup_index = site_dir / f".index.html{suffix}"
real_index = site_dir / "index.html"
if backup_index.exists():
real_index.write_bytes(backup_index.read_bytes())
backup_index.unlink()
def sync_visibility(name: str):
"""Ensure filesystem state matches enabled+published state.
Only one file should exist: index.html (visible) or .index.html.bak (hidden)."""
meta = load_meta()
info = meta.get("sites", {}).get(name, {})
visible = info.get("enabled", True) and info.get("published", False)
site_dir = DEMO_ROOT / name
real_index = site_dir / "index.html"
draft_index = site_dir / ".index.html.draft"
bak_index = site_dir / ".index.html.bak"
if visible:
# Site should be public. Restore from any backup.
for src in [draft_index, bak_index]:
if src.exists() and not real_index.exists():
real_index.write_bytes(src.read_bytes())
src.unlink()
break
else:
# Site should be hidden.
if real_index.exists():
if not info.get("enabled", True):
# Disabled — use .bak
bak_index.write_bytes(real_index.read_bytes())
real_index.unlink()
elif not info.get("published", False):
# Unpublished draft — use .draft
draft_index.write_bytes(real_index.read_bytes())
real_index.unlink()
# Clean up leftover backup files
for leftover in [draft_index, bak_index]:
if leftover.exists() and real_index.exists():
leftover.unlink()
# ─── API Routes ─────────────────────────────────────
@app.get("/api/health")
def health():
@@ -142,8 +164,20 @@ def list_sites(request: Request):
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})
try:
file_count = sum(1 for _ in site_dir.rglob("*") if _.is_file() and not _.name.startswith("."))
except OSError:
pass
# Backward compat: treat missing 'published' as True
if "published" not in info:
info["published"] = True
sites.append({
**info, "name": name,
"exists": site_dir.is_dir() if not isinstance(site_dir.is_dir(), OSError) else info.get("published", False),
"file_count": file_count,
"url": f"https://demo.junv.cc/{name}" if is_site_visible(name) else None,
"public": is_site_visible(name),
})
return {"sites": sites, "user": user}
@app.post("/api/sites")
@@ -154,32 +188,39 @@ def create_site(body: SiteCreate, request: Request):
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>
# Create a default index.html (always starts as a real file)
default_html = 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>""")
</html>"""""
(site_dir / "index.html").write_text(default_html)
site_info = {
"name": name,
"enabled": True,
"published": body.published, # NEW
"description": body.description,
"created_at": now,
"updated_at": now,
}
meta["sites"][name] = site_info
save_meta(meta)
# Sync visibility
sync_visibility(name)
return site_info
@app.delete("/api/sites/{name}")
@@ -187,16 +228,14 @@ 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
pass
return {"status": "deleted", "name": name}
@app.post("/api/sites/{name}/enable")
@@ -204,12 +243,12 @@ 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)
sync_visibility(name)
return {"status": "enabled", "name": name}
@app.post("/api/sites/{name}/disable")
@@ -217,72 +256,120 @@ 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)
sync_visibility(name)
return {"status": "disabled", "name": name}
@app.post("/api/sites/{name}/publish")
def publish_site(name: str, request: Request):
"""Make a draft site public."""
user = require_auth(request)
if not site_exists(name):
raise HTTPException(status_code=404, detail=f"Site '{name}' not found")
meta = load_meta()
if name in meta.get("sites", {}):
meta["sites"][name]["published"] = True
meta["sites"][name]["updated_at"] = datetime.now(timezone.utc).isoformat()
save_meta(meta)
sync_visibility(name)
return {
"status": "published",
"name": name,
"url": f"https://demo.junv.cc/{name}",
}
@app.post("/api/sites/{name}/unpublish")
def unpublish_site(name: str, request: Request):
"""Make a public site draft (hidden from public)."""
user = require_auth(request)
if not site_exists(name):
raise HTTPException(status_code=404, detail=f"Site '{name}' not found")
meta = load_meta()
if name in meta.get("sites", {}):
meta["sites"][name]["published"] = False
meta["sites"][name]["updated_at"] = datetime.now(timezone.utc).isoformat()
save_meta(meta)
sync_visibility(name)
return {"status": "unpublished", "name": name, "note": "Site is now a draft, accessible only via preview API"}
@app.get("/api/sites/{name}/preview")
def preview_site(name: str, request: Request):
"""Preview a draft site (requires auth). Returns the HTML content."""
user = require_auth(request)
if not site_exists(name):
raise HTTPException(status_code=404, detail=f"Site '{name}' not found")
site_dir = DEMO_ROOT / name
# Check for index.html (published), then .index.html.draft, then .index.html.bak
for candidate in ["index.html", ".index.html.draft", ".index.html.bak"]:
fpath = site_dir / candidate
if fpath.exists():
return HTMLResponse(fpath.read_text())
raise HTTPException(status_code=404, detail="No content found for this site")
@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})
info = meta.get("sites", {}).get(name, {"name": name, "enabled": True, "published": False})
# Backward compat
if "published" not in info:
info["published"] = 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}"}
try:
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})
except OSError:
pass
return {
"site": info,
"files": files,
"public": is_site_visible(name),
"url": f"https://demo.junv.cc/{name}" if is_site_visible(name) else None,
"preview_url": f"/api/sites/{name}/preview",
}
@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))}"
"url": f"https://demo.junv.cc/{name}/{str(file_path.relative_to(site_dir))}" if is_site_visible(name) else None,
}
@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}")
@@ -290,12 +377,11 @@ def delete_file(name: str, request: Request, path: str = Form("")):
shutil.rmtree(file_path)
else:
file_path.unlink()
return {"status": "deleted", "name": name, "file": safe_path}
# ─── Web UI ─────────────────────────────────────────
UI_HTML = """<!DOCTYPE html>
UI_HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
@@ -314,6 +400,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
.btn:hover{background:#30363d}
.btn-primary{background:#1f6feb;border-color:#1f6feb;color:#fff}
.btn-primary:hover{background:#388bfd}
.btn-success{background:#238636;border-color:#238636;color:#fff}
.btn-success:hover{background:#2ea043}
.btn-warning{background:#9e6a03;border-color:#9e6a03;color:#fff}
.btn-warning:hover{background:#bb8009}
.btn-danger{background:#da3633;border-color:#da3633;color:#fff}
.btn-danger:hover{background:#f85149}
.btn-sm{padding:4px 10px;font-size:12px}
@@ -324,10 +414,12 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
.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}
.site-card .actions{display:flex;gap:6px}
.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}
.badge-draft{background:#3a2e1b;color:#d29922}
.badge-published{background:#1b3a1b;color:#3fb950}
.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}
@@ -338,267 +430,200 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
.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-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;align-items:center;justify-content:center;z-index:100}
.modal{background:#161b22;border:1px solid #30363d;border-radius:12px;padding:24px;max-width:560px;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}
.spinner{display:inline-block;width:16px;height:16px;border:2px solid #30363d;border-top-color:#58a6ff;border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle;margin-right:6px}
@keyframes spin{to{transform:rotate(360deg)}}
.help-text{font-size:12px;color:#6e7681;margin-top:4px}
</style>
</head>
<body>
<div class="header">
<h1>🧪 Demo Manager</h1>
<div class="user" id="user-info">Loading...</div>
</div>
<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>
<h2> New Demo Site <span style="font-size:12px;font-weight:400;color:#6e7681">— drafts are hidden until published</span></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>
<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>
<div class="form-group" style="display:flex;align-items:center;gap:6px"><input type="checkbox" id="site-publish" style="width:auto"><label for="site-publish" style="margin:0;font-size:13px">Publish now</label></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 class="section"><h2>📂 Sites</h2><div id="sites-list">Loading...</div></div>
</div>
<div id="file-modal" class="modal-overlay"><div class="modal" id="file-modal-content"></div></div>
<div id="preview-modal" class="modal-overlay"><div class="modal" id="preview-modal-content" style="max-width:90%;max-height:90vh;overflow:auto"></div></div>
<div id="toast-container"></div>
<script>
let currentUser = '';
const API = '/api/sites';
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');
}
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');
}
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 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 formatSize(b){if(b<1024)return b+' B';if(b<1048576)return(b/1024).toFixed(1)+' KB';return(b/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+' (Pocket ID)';
if(!data.sites.length){container.innerHTML='<p style="color:#8b949e;text-align:center;padding:32px">No sites yet. Create one above!</p>';return;}
container.innerHTML=data.sites.map(s=>{
const isPublic=s.public;
const isEnabled=s.enabled!==false;
let statusBadge='';
if(!isEnabled)statusBadge='<span class="badge badge-off">DISABLED</span>';
else if(s.published)statusBadge='<span class="badge badge-published">🔓 PUBLISHED</span>';
else statusBadge='<span class="badge badge-draft">🔒 DRAFT</span>';
let actionBtns='';
if(!isEnabled){
actionBtns+='<button class="btn btn-sm btn-success" onclick="toggleSite(\''+s.name+'\',\'enable\')">▶ Enable</button>';
}else{
actionBtns+='<button class="btn btn-sm btn-danger" onclick="toggleSite(\''+s.name+'\',\'disable\')">⏸ Disable</button>';
if(s.published){
actionBtns+='<button class="btn btn-sm btn-warning" onclick="publishSite(\''+s.name+'\',\'unpublish\')">🔒 Unpublish</button>';
}else{
actionBtns+='<button class="btn btn-sm btn-success" onclick="publishSite(\''+s.name+'\',\'publish\')">🔓 Publish</button>';
}
}
actionBtns+='<button class="btn btn-sm" onclick="manageSite(\''+s.name+'\')">📁 Files</button>';
if(!isPublic){actionBtns+='<button class="btn btn-sm" onclick="previewSite(\''+s.name+'\')">👁 Preview</button>';}
actionBtns+='<button class="btn btn-sm btn-danger" onclick="deleteSite(\''+s.name+'\')">🗑</button>';
return '<div class="site-card"><div class="info"><div class="name">'+
(isPublic?'<a href="https://demo.junv.cc/'+s.name+'" target="_blank">'+s.name+'</a>':'<span>'+s.name+'</span>')+
' '+statusBadge+
'</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">'+actionBtns+'</div></div>';
}).join('');
}catch(e){container.innerHTML='<p style="color:#f85149">'+e.message+'</p>';}
}
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) {
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');
const name=document.getElementById('site-name').value.trim();
const desc=document.getElementById('site-desc').value.trim();
const publishNow=document.getElementById('site-publish').checked;
if(!name)return;
try{
await api('POST',API,{name,description:desc,published:publishNow});
document.getElementById('site-name').value='';
document.getElementById('site-desc').value='';
document.getElementById('site-publish').checked=false;
toast(publishNow?'Site "'+name+'" created and published!':'Site "'+name+'" created as draft','success');
loadSites();
} catch (e) {
toast(e.message, 'error');
}catch(e){toast(e.message,'error');}
}
async function publishSite(name,action){
try{
await api('POST',API+'/'+name+'/'+action);
toast(action==='publish'?'🔓 Published! Live at demo.junv.cc/'+name:'🔒 Unpublished — back to draft','success');
loadSites();
}catch(e){toast(e.message,'error');}
}
async function toggleSite(name,action){
try{await api('POST',API+'/'+name+'/'+action);toast('Site '+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('Deleted','success');loadSites();}
catch(e){toast(e.message,'error');}
}
async function previewSite(name){
const modal=document.getElementById('preview-modal');
const content=document.getElementById('preview-modal-content');
modal.style.display='flex';
content.innerHTML='<h3>👁 Preview: '+name+' <span style="font-size:12px;color:#d29922">(Draft — requires auth)</span></h3><div style="text-align:center;padding:24px"><span class="spinner"></span>Loading preview...</div>';
try{
const res=await fetch(API+'/'+name+'/preview');
if(!res.ok)throw new Error((await res.json().catch(()=>({detail:'Failed'}))).detail||'Failed');
const html=await res.text();
content.innerHTML='<h3>👁 Preview: '+name+' <span style="font-size:12px;color:#d29922">(Draft)</span></h3><div style="text-align:right;margin-bottom:8px"><button class="btn btn-sm" onclick="document.getElementById(\'preview-modal\').style.display=\'none\'">Close</button></div><iframe srcdoc="'+html.replace(/"/g,'&quot;')+'" style="width:100%;height:70vh;border:1px solid #30363d;border-radius:8px;background:#fff"></iframe>';
}catch(e){
content.innerHTML='<h3>Preview Error</h3><p style="color:#f85149">'+e.message+'</p><button class="btn btn-sm" onclick="document.getElementById(\'preview-modal\').style.display=\'none\'">Close</button>';
}
}
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 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"><div class="upload-zone" id="upload-zone-'+name+'"><div id="upload-label-'+name+'">📤 Drop files 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><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 btn-sm" onclick="document.getElementById(\'file-modal\').style.display=\'none\'">Close</button></div>';
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>';
try{
const data=await api('GET',API+'/'+name);
const fd=document.getElementById('files-list-'+name);
if(!data.files.length){fd.innerHTML='<p style="color:#8b949e">No files. Upload one above.</p>';}
else{
fd.innerHTML='<ul class="file-list">'+data.files.map(f=>'<li><span><a href="'+API+'/'+name+'/preview?file='+encodeURIComponent(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 = '';
};
}catch(e){document.getElementById('files-list-'+name).innerHTML='<p style="color:#f85149">'+e.message+'</p>';}
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='';await handleUpload(name,e.dataTransfer.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>`;
}
async function handleUpload(siteName,files){
const pd=document.getElementById('upload-progress-'+siteName);
const label=document.getElementById('upload-label-'+siteName);
pd.style.display='block';
for(const file of files){
pd.innerHTML='<span class="spinner"></span> Uploading '+file.name+'...';
try{await uploadFile(siteName,file,'');pd.innerHTML+=''+file.name+'<br>';}
catch(e){pd.innerHTML+=''+file.name+': '+e.message+'<br>';}
}
label.textContent = '📤 Drop more files or click to upload';
toast('Upload complete!', 'success');
manageSite(siteName); // Refresh
label.textContent='📤 Drop more files or click to upload';
toast('Upload complete!','success');
manageSite(siteName);
}
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');
}
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);
}catch(e){toast(e.message,'error');}
}
document.getElementById('create-form').onsubmit = createSite;
document.getElementById('create-form').onsubmit=createSite;
loadSites();
</script>
</body>
@@ -608,13 +633,11 @@ loadSites();
@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>