demo-service: add three-tier visibility (disabled/internal/public)

- Replace old enabled/published fields with single visibility field:
  - disabled → 404 for everyone (admin sees in admin UI only)
  - internal → 192.168.1.x access without auth; external → 404
  - public → anyone can access
- Admin users can see all sites via admin UI regardless of visibility
- Add /api/sites/{name}/visibility endpoint to change visibility
- Keep backward-compat enable/disable/publish/unpublish endpoints
- Database auto-migration from old enabled+published to new visibility
- NFS-safe sync_visibility hides/shows index.html per mode
This commit is contained in:
Junv (via Hermes)
2026-07-13 10:23:29 +10:00
parent a691553d89
commit 2679287543
2 changed files with 279 additions and 145 deletions
+72 -38
View File
@@ -9,6 +9,8 @@ DB_PATH = Path(os.environ.get("DEMO_DB_PATH", "/data/demos/.meta/sites.db"))
_lock = threading.Lock() _lock = threading.Lock()
_conn: Optional[sqlite3.Connection] = None _conn: Optional[sqlite3.Connection] = None
VALID_VISIBILITY = frozenset({"disabled", "internal", "public"})
def get_conn() -> sqlite3.Connection: def get_conn() -> sqlite3.Connection:
global _conn global _conn
@@ -26,14 +28,54 @@ def _init_schema(conn: sqlite3.Connection):
name TEXT PRIMARY KEY, name TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1,
published INTEGER NOT NULL DEFAULT 0, published INTEGER NOT NULL DEFAULT 0,
visibility TEXT NOT NULL DEFAULT 'internal',
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
) )
""") """)
# Migration: populate visibility from old enabled/published columns
conn.execute("""
UPDATE sites SET visibility = 'disabled' WHERE enabled = 0 AND visibility = 'internal'
""")
conn.execute("""
UPDATE sites SET visibility = 'public' WHERE enabled = 1 AND published = 1 AND visibility = 'internal'
""")
# Future-proof: ensure no invalid visibility values
conn.execute("""
UPDATE sites SET visibility = 'internal' WHERE visibility NOT IN ('disabled', 'internal', 'public')
""")
conn.commit() conn.commit()
# ─── Converters ───────────────────────────
def _visibility_from_old(enabled: bool, published: bool) -> str:
"""Convert old enabled+published to new visibility."""
if not enabled:
return "disabled"
if published:
return "public"
return "internal"
def _row_to_dict(r) -> dict:
"""Convert a SQLite Row to dict with both old and new fields."""
return {
"name": r["name"],
"enabled": r["visibility"] != "disabled",
"published": r["visibility"] == "public",
"visibility": r["visibility"],
"description": r["description"],
"created_at": r["created_at"],
"updated_at": r["updated_at"],
}
# ─── Migration ────────────────────────────
def migrate_from_json(): def migrate_from_json():
"""One-time migration from old sites.json to SQLite DB.""" """One-time migration from old sites.json to SQLite DB."""
json_path = DB_PATH.parent / "sites.json" json_path = DB_PATH.parent / "sites.json"
@@ -47,20 +89,19 @@ def migrate_from_json():
try: try:
data = json.loads(json_path.read_text()) data = json.loads(json_path.read_text())
for name, info in data.get("sites", {}).items(): for name, info in data.get("sites", {}).items():
# Backward compat: missing published defaults to True (existing sites stay public) published = info.get("published", True)
published = 1 if info.get("published", True) else 0 enabled = info.get("enabled", True)
enabled = 1 if info.get("enabled", True) else 0 visibility = _visibility_from_old(enabled, published)
conn.execute( conn.execute(
"""INSERT OR REPLACE INTO sites """INSERT OR REPLACE INTO sites
(name, enabled, published, description, created_at, updated_at) (name, enabled, published, visibility, description, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?)""",
(name, enabled, published, (name, 1 if enabled else 0, 1 if published else 0, visibility,
info.get("description", ""), info.get("description", ""),
info.get("created_at", ""), info.get("created_at", ""),
info.get("updated_at", "")), info.get("updated_at", "")),
) )
conn.commit() conn.commit()
# Rename old file so we don't re-migrate
json_path.rename(json_path.with_suffix(".json.migrated")) json_path.rename(json_path.with_suffix(".json.migrated"))
return True return True
except Exception as e: except Exception as e:
@@ -68,50 +109,43 @@ def migrate_from_json():
return False return False
# ─── CRUD ────────────────────────────────
def load_all() -> dict: def load_all() -> dict:
"""Return sites as {name: info_dict} for backward compat.""" """Return sites as {name: info_dict} for backward compat."""
conn = get_conn() conn = get_conn()
rows = conn.execute( rows = conn.execute(
"SELECT name, enabled, published, description, created_at, updated_at FROM sites" "SELECT name, visibility, description, created_at, updated_at FROM sites"
).fetchall() ).fetchall()
sites = {} sites = {}
for r in rows: for r in rows:
sites[r["name"]] = { sites[r["name"]] = _row_to_dict(r)
"name": r["name"],
"enabled": bool(r["enabled"]),
"published": bool(r["published"]),
"description": r["description"],
"created_at": r["created_at"],
"updated_at": r["updated_at"],
}
return {"sites": sites} return {"sites": sites}
def get(name: str) -> Optional[dict]: def get(name: str) -> Optional[dict]:
conn = get_conn() conn = get_conn()
r = conn.execute( r = conn.execute(
"SELECT name, enabled, published, description, created_at, updated_at FROM sites WHERE name = ?", "SELECT name, visibility, description, created_at, updated_at FROM sites WHERE name = ?",
(name,), (name,),
).fetchone() ).fetchone()
if not r: if not r:
return None return None
return { return _row_to_dict(r)
"name": r["name"],
"enabled": bool(r["enabled"]),
"published": bool(r["published"]),
"description": r["description"],
"created_at": r["created_at"],
"updated_at": r["updated_at"],
}
def insert(name: str, enabled: bool, published: bool, description: str, now: str): def insert(name: str, visibility: str, description: str, now: str):
if visibility not in VALID_VISIBILITY:
raise ValueError(f"Invalid visibility: {visibility}")
enabled = 1 if visibility != "disabled" else 0
published = 1 if visibility == "public" else 0
with _lock: with _lock:
conn = get_conn() conn = get_conn()
conn.execute( conn.execute(
"""INSERT INTO sites (name, enabled, published, description, created_at, updated_at) """INSERT INTO sites (name, enabled, published, visibility, description, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?)""",
(name, 1 if enabled else 0, 1 if published else 0, description, now, now), (name, enabled, published, visibility, description, now, now),
) )
conn.commit() conn.commit()
@@ -119,18 +153,18 @@ def insert(name: str, enabled: bool, published: bool, description: str, now: str
def update(name: str, **kwargs): def update(name: str, **kwargs):
if not kwargs: if not kwargs:
return return
# Handle visibility specially — sync enabled/published
if "visibility" in kwargs:
v = kwargs["visibility"]
if v not in VALID_VISIBILITY:
raise ValueError(f"Invalid visibility: {v}")
kwargs["enabled"] = 1 if v != "disabled" else 0
kwargs["published"] = 1 if v == "public" else 0
cols = [] cols = []
vals = [] vals = []
for k, v in kwargs.items(): for k, v in kwargs.items():
if k == "enabled": cols.append(f"{k} = ?")
cols.append("enabled = ?") vals.append(v)
vals.append(1 if v else 0)
elif k == "published":
cols.append("published = ?")
vals.append(1 if v else 0)
else:
cols.append(f"{k} = ?")
vals.append(v)
vals.append(name) vals.append(name)
with _lock: with _lock:
conn = get_conn() conn = get_conn()
+207 -107
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Demo Manager — FastAPI service for managing demo sites. Demo Manager — FastAPI service for managing demo sites.
Sites start as drafts (unpublished). Publishing makes them public. Three visibility modes: disabled, internal (192.168.1.x), public.
Pocket ID SSO + API key auth. Pocket ID SSO + API key auth for management.
""" """
import ipaddress
import json import json
import os import os
import shutil import shutil
@@ -23,29 +24,49 @@ from _config import API_KEY, OAUTH_PROXY
from _loaders import AUTH_REQUIRED_HTML, UI_HTML from _loaders import AUTH_REQUIRED_HTML, UI_HTML
import _db import _db
app = FastAPI(title="Demo Manager", version="1.2.0") app = FastAPI(title="Demo Manager", version="2.0.0")
# ─── Internal network detection ───────────
INTERNAL_NETS = [ipaddress.ip_network("192.168.1.0/24")]
def is_internal_ip(request: Request) -> bool:
"""Check if the request originated from an internal network."""
forwarded = request.headers.get("X-Forwarded-For", "")
if forwarded:
client_ip = forwarded.split(",")[0].strip()
elif request.client:
client_ip = request.client.host
else:
return False
try:
ip = ipaddress.ip_address(client_ip)
return any(ip in net for net in INTERNAL_NETS)
except ValueError:
return False
# ─── Models ───────────────────────────────────────── # ─── Models ─────────────────────────────────────────
class SiteInfo(BaseModel): class SiteInfo(BaseModel):
name: str name: str
enabled: bool = True visibility: str = "internal"
published: bool = False
description: str = "" description: str = ""
created_at: str = "" created_at: str = ""
updated_at: str = "" updated_at: str = ""
class SiteCreate(BaseModel): class SiteCreate(BaseModel):
name: str name: str
visibility: str = "internal"
description: str = "" description: str = ""
published: bool = False
class VisibilityUpdate(BaseModel):
visibility: str
# ─── Helpers ──────────────────────────────────────── # ─── Helpers ────────────────────────────────────────
# Metadata is persisted in SQLite via _db module
load_meta = _db.load_all
def save_meta(meta: dict):
"""Compatibility shim. New code should use _db directly."""
pass # mutations go through _db.insert/update/delete
def check_auth(request: Request) -> Optional[str]: def check_auth(request: Request) -> Optional[str]:
auth_header = request.headers.get("Authorization", "") auth_header = request.headers.get("Authorization", "")
@@ -72,15 +93,17 @@ def check_auth(request: Request) -> Optional[str]:
pass pass
return None return None
def require_auth(request: Request) -> str: def require_auth(request: Request) -> str:
user = check_auth(request) user = check_auth(request)
if not user: if not user:
raise HTTPException(status_code=401, detail="Authentication required") raise HTTPException(status_code=401, detail="Authentication required")
return user return user
def site_exists(name: str) -> bool: def site_exists(name: str) -> bool:
meta = load_meta() info = _db.get(name)
if name not in meta.get("sites", {}): if not info:
return False return False
site_dir = DEMO_ROOT / name site_dir = DEMO_ROOT / name
try: try:
@@ -88,33 +111,40 @@ def site_exists(name: str) -> bool:
except OSError: except OSError:
return True return True
def is_site_visible(name: str) -> bool:
meta = load_meta() def error_resp(status_code: int = 404) -> HTMLResponse:
info = meta.get("sites", {}).get(name, {}) resp = HTMLResponse(AUTH_REQUIRED_HTML, status_code=status_code)
return info.get("enabled", True) and info.get("published", False) resp.headers["Content-Disposition"] = "inline"
resp.headers["Cache-Control"] = "no-store"
return resp
# ─── File Helpers (NFS safe — no rename) ───────────── # ─── File Helpers (NFS safe — no rename) ─────────────
def sync_visibility(name: str): def sync_visibility(name: str):
meta = load_meta() """Sync the on-disk index.html based on visibility mode."""
info = meta.get("sites", {}).get(name, {}) info = _db.get(name)
visible = info.get("enabled", True) and info.get("published", False) if not info:
return
v = info["visibility"]
site_dir = DEMO_ROOT / name site_dir = DEMO_ROOT / name
real_index = site_dir / "index.html" real_index = site_dir / "index.html"
draft_index = site_dir / ".index.html.draft" draft_index = site_dir / ".index.html.draft"
bak_index = site_dir / ".index.html.bak" bak_index = site_dir / ".index.html.bak"
if visible: if v == "public":
# Ensure public site has visible index.html
for src in [draft_index, bak_index]: for src in [draft_index, bak_index]:
if src.exists() and not real_index.exists(): if src.exists() and not real_index.exists():
real_index.write_bytes(src.read_bytes()) real_index.write_bytes(src.read_bytes())
src.unlink() src.unlink()
break break
else: else:
# disabled or internal → hide index.html
if real_index.exists(): if real_index.exists():
if not info.get("enabled", True): if v == "disabled":
bak_index.write_bytes(real_index.read_bytes()) bak_index.write_bytes(real_index.read_bytes())
real_index.unlink() real_index.unlink()
elif not info.get("published", False): else: # internal
draft_index.write_bytes(real_index.read_bytes()) draft_index.write_bytes(real_index.read_bytes())
real_index.unlink() real_index.unlink()
@@ -122,37 +152,53 @@ def sync_visibility(name: str):
if leftover.exists() and real_index.exists(): if leftover.exists() and real_index.exists():
leftover.unlink() leftover.unlink()
# ─── API Routes ───────────────────────────────────── # ─── API Routes ─────────────────────────────────────
@app.get("/api/health") @app.get("/api/health")
def health(): def health():
return {"status": "ok", "root": str(DEMO_ROOT)} return {"status": "ok", "root": str(DEMO_ROOT)}
@app.get("/api/error-page", response_class=HTMLResponse) @app.get("/api/error-page", response_class=HTMLResponse)
def error_page(): def error_page():
"""Serve the canvas error page for any HTTP error."""
return HTMLResponse(AUTH_REQUIRED_HTML) return HTMLResponse(AUTH_REQUIRED_HTML)
@app.get("/site-content", response_class=FileResponse)
# ─── Site content serving (access control) ──────────
@app.get("/site-content/{path:path}") @app.get("/site-content/{path:path}")
async def site_content(path: str = "", request: Request = None): async def site_content(path: str = "", request: Request = None):
"""Serve site files. Drafts require auth.""" """Serve site files with visibility-based access control."""
if not path: if not path:
raise HTTPException(404) raise HTTPException(404)
parts = path.split("/", 1) parts = path.split("/", 1)
site_name = parts[0] site_name = parts[0]
sub = parts[1] if len(parts) > 1 else "" sub = parts[1] if len(parts) > 1 else ""
if not site_name or not site_exists(site_name):
resp = HTMLResponse(AUTH_REQUIRED_HTML, status_code=404) info = _db.get(site_name)
resp.headers["Content-Disposition"] = "inline" if not info:
resp.headers["Cache-Control"] = "no-store" return error_resp(404)
return resp
visible = is_site_visible(site_name) visibility = info["visibility"]
if not visible: user = check_auth(request)
if not check_auth(request):
resp = HTMLResponse(AUTH_REQUIRED_HTML, status_code=401) # ── Access control ──
resp.headers["Content-Disposition"] = "inline" if visibility == "disabled":
resp.headers["Cache-Control"] = "no-store" # No one can access disabled sites via public URL
return resp if not user:
return error_resp(404)
# Even auth'd users get 404 on disabled sites (use admin UI instead)
return error_resp(404)
elif visibility == "internal":
# Internal IPs can access without auth; external needs auth
if not user and not is_internal_ip(request):
# External + no auth → 404 (no login prompt)
return error_resp(404)
# visibility == "public" → everyone can access
# ── Serve file ──
site_dir = DEMO_ROOT / site_name site_dir = DEMO_ROOT / site_name
target = (site_dir / sub) if sub else (site_dir / "index.html") target = (site_dir / sub) if sub else (site_dir / "index.html")
if not target.exists(): if not target.exists():
@@ -163,50 +209,54 @@ async def site_content(path: str = "", request: Request = None):
target = p target = p
break break
if not target.exists(): if not target.exists():
resp = HTMLResponse(AUTH_REQUIRED_HTML, status_code=404) return error_resp(404)
resp.headers["Content-Disposition"] = "inline"
resp.headers["Cache-Control"] = "no-store"
return resp
if target.is_dir(): if target.is_dir():
target = target / "index.html" target = target / "index.html"
if not target.exists(): if not target.exists():
resp = HTMLResponse(AUTH_REQUIRED_HTML, status_code=404) return error_resp(404)
resp.headers["Content-Disposition"] = "inline" # HTML files render inline; others (CSS, JS, images) served as files
resp.headers["Cache-Control"] = "no-store"
return resp
# HTML files render inline; others (CSS, JS, images) are served as files
# .draft and .bak are hidden HTML fallbacks (e.g. .index.html.draft)
if target.suffix == ".html" or target.suffix in (".draft", ".bak"): if target.suffix == ".html" or target.suffix in (".draft", ".bak"):
return HTMLResponse(target.read_text()) return HTMLResponse(target.read_text())
resp = FileResponse(target) resp = FileResponse(target)
resp.headers["Content-Disposition"] = "inline" resp.headers["Content-Disposition"] = "inline"
return resp return resp
# ─── Site management ────────────────────────────────
def _site_to_dict(name: str, info: dict) -> dict:
"""Build the public site dict from DB info + filesystem."""
site_dir = DEMO_ROOT / name
file_count = 0
if site_dir.is_dir():
try:
file_count = sum(1 for _ in site_dir.rglob("*") if _.is_file() and not _.name.startswith("."))
except OSError:
pass
return {
"name": name,
"visibility": info["visibility"],
"description": info["description"],
"created_at": info["created_at"],
"updated_at": info["updated_at"],
"exists": True,
"file_count": file_count,
"url": f"https://demo.junv.cc/{name}" if info["visibility"] == "public" else None,
"public": info["visibility"] == "public",
}
@app.get("/api/sites") @app.get("/api/sites")
def list_sites(request: Request): def list_sites(request: Request):
user = require_auth(request) user = require_auth(request)
meta = load_meta() meta = _db.load_all()
sites = [] sites = []
for name in sorted(meta.get("sites", {}).keys()): for name in sorted(meta.get("sites", {}).keys()):
info = meta["sites"][name] info = meta["sites"][name]
site_dir = DEMO_ROOT / name sites.append(_site_to_dict(name, info))
file_count = 0
if site_dir.is_dir():
try:
file_count = sum(1 for _ in site_dir.rglob("*") if _.is_file() and not _.name.startswith("."))
except OSError:
pass
if "published" not in info:
info["published"] = True
sites.append({
**info, "name": name,
"exists": True,
"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} return {"sites": sites, "user": user}
@app.post("/api/sites") @app.post("/api/sites")
def create_site(body: SiteCreate, request: Request): def create_site(body: SiteCreate, request: Request):
user = require_auth(request) user = require_auth(request)
@@ -216,8 +266,6 @@ def create_site(body: SiteCreate, request: Request):
if site_exists(name): if site_exists(name):
raise HTTPException(status_code=409, detail=f"Site '{name}' already exists") raise HTTPException(status_code=409, detail=f"Site '{name}' already exists")
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
meta = load_meta()
meta.setdefault("sites", {})
site_dir = DEMO_ROOT / name site_dir = DEMO_ROOT / name
site_dir.mkdir(parents=True, exist_ok=True) site_dir.mkdir(parents=True, exist_ok=True)
default_html = f"""<!DOCTYPE html> default_html = f"""<!DOCTYPE html>
@@ -230,13 +278,15 @@ def create_site(body: SiteCreate, request: Request):
(site_dir / "index.html").write_text(default_html) (site_dir / "index.html").write_text(default_html)
_db.insert( _db.insert(
name=name, name=name,
enabled=True, visibility=body.visibility if body.visibility in ("disabled", "internal", "public") else "internal",
published=body.published,
description=body.description, description=body.description,
now=now, now=now,
) )
sync_visibility(name) sync_visibility(name)
return site_info info = _db.get(name)
assert info is not None
return _site_to_dict(name, info)
@app.delete("/api/sites/{name}") @app.delete("/api/sites/{name}")
def delete_site(name: str, request: Request): def delete_site(name: str, request: Request):
@@ -245,61 +295,86 @@ def delete_site(name: str, request: Request):
raise HTTPException(status_code=404, detail=f"Site '{name}' not found") raise HTTPException(status_code=404, detail=f"Site '{name}' not found")
_db.delete(name) _db.delete(name)
site_dir = DEMO_ROOT / name site_dir = DEMO_ROOT / name
try: shutil.rmtree(site_dir) try:
except OSError: pass shutil.rmtree(site_dir)
except OSError:
pass
return {"status": "deleted", "name": name} return {"status": "deleted", "name": name}
# ── Visibility management ──────────────────────────
@app.post("/api/sites/{name}/visibility")
def set_visibility(name: str, body: VisibilityUpdate, request: Request):
"""Change site visibility: disabled | internal | public."""
user = require_auth(request)
if not site_exists(name):
raise HTTPException(status_code=404)
v = body.visibility
if v not in ("disabled", "internal", "public"):
raise HTTPException(status_code=400, detail="visibility must be: disabled, internal, or public")
_db.update(name, visibility=v, updated_at=datetime.now(timezone.utc).isoformat())
sync_visibility(name)
info = _db.get(name)
assert info is not None
return _site_to_dict(name, info)
# ── Backward-compat endpoints (deprecated) ─────────
@app.post("/api/sites/{name}/enable") @app.post("/api/sites/{name}/enable")
def enable_site(name: str, request: Request): def enable_site(name: str, request: Request):
# Old "enable" → set visibility to last non-disabled value or public
user = require_auth(request) user = require_auth(request)
if not site_exists(name): raise HTTPException(status_code=404) if not site_exists(name):
_db.update(name, enabled=True, updated_at=datetime.now(timezone.utc).isoformat()) raise HTTPException(status_code=404)
info = _db.get(name)
v = "public" if info and info.get("published") else "internal"
_db.update(name, visibility=v, updated_at=datetime.now(timezone.utc).isoformat())
sync_visibility(name) sync_visibility(name)
return {"status": "enabled", "name": name} return {"status": "enabled", "name": name, "visibility": v}
@app.post("/api/sites/{name}/disable") @app.post("/api/sites/{name}/disable")
def disable_site(name: str, request: Request): def disable_site(name: str, request: Request):
user = require_auth(request) user = require_auth(request)
if not site_exists(name): raise HTTPException(status_code=404) if not site_exists(name):
_db.update(name, enabled=False, updated_at=datetime.now(timezone.utc).isoformat()) raise HTTPException(status_code=404)
_db.update(name, visibility="disabled", updated_at=datetime.now(timezone.utc).isoformat())
sync_visibility(name) sync_visibility(name)
return {"status": "disabled", "name": name} return {"status": "disabled", "name": name}
@app.post("/api/sites/{name}/publish") @app.post("/api/sites/{name}/publish")
def publish_site(name: str, request: Request): def publish_site(name: str, request: Request):
user = require_auth(request) user = require_auth(request)
if not site_exists(name): raise HTTPException(status_code=404) if not site_exists(name):
_db.update(name, published=True, updated_at=datetime.now(timezone.utc).isoformat()) raise HTTPException(status_code=404)
_db.update(name, visibility="public", updated_at=datetime.now(timezone.utc).isoformat())
sync_visibility(name) sync_visibility(name)
return {"status": "published", "name": name, "url": f"https://demo.junv.cc/{name}"} return {"status": "published", "name": name, "url": f"https://demo.junv.cc/{name}"}
@app.post("/api/sites/{name}/unpublish") @app.post("/api/sites/{name}/unpublish")
def unpublish_site(name: str, request: Request): def unpublish_site(name: str, request: Request):
user = require_auth(request) user = require_auth(request)
if not site_exists(name): raise HTTPException(status_code=404) if not site_exists(name):
_db.update(name, published=False, updated_at=datetime.now(timezone.utc).isoformat()) raise HTTPException(status_code=404)
_db.update(name, visibility="internal", updated_at=datetime.now(timezone.utc).isoformat())
sync_visibility(name) sync_visibility(name)
return {"status": "unpublished", "name": name} return {"status": "unpublished", "name": name}
@app.get("/api/sites/{name}/preview")
def preview_site(name: str, request: Request): # ── File management ────────────────────────────────
user = require_auth(request)
if not site_exists(name): raise HTTPException(status_code=404)
site_dir = DEMO_ROOT / name
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")
@app.get("/api/sites/{name}") @app.get("/api/sites/{name}")
def get_site(name: str, request: Request): def get_site(name: str, request: Request):
require_auth(request) require_auth(request)
if not site_exists(name): raise HTTPException(status_code=404) if not site_exists(name):
meta = load_meta() raise HTTPException(status_code=404)
info = meta.get("sites", {}).get(name, {"name": name, "enabled": True, "published": False}) info = _db.get(name)
if "published" not in info: if not info:
info["published"] = True raise HTTPException(status_code=404)
site_dir = DEMO_ROOT / name site_dir = DEMO_ROOT / name
files = [] files = []
if site_dir.is_dir(): if site_dir.is_dir():
@@ -308,17 +383,21 @@ def get_site(name: str, request: Request):
if f.is_file() and not f.name.startswith("."): if f.is_file() and not f.name.startswith("."):
rel = str(f.relative_to(site_dir)) rel = str(f.relative_to(site_dir))
files.append({"path": rel, "size": f.stat().st_size}) files.append({"path": rel, "size": f.stat().st_size})
except OSError: pass except OSError:
pass
return { return {
"site": info, "files": files, "site": info,
"public": is_site_visible(name), "files": files,
"url": f"https://demo.junv.cc/{name}" if is_site_visible(name) else None, "public": info["visibility"] == "public",
"url": f"https://demo.junv.cc/{name}" if info["visibility"] == "public" else None,
} }
@app.post("/api/sites/{name}/files") @app.post("/api/sites/{name}/files")
async def upload_file(name: str, request: Request, file: UploadFile = File(...), path: str = Form("")): async def upload_file(name: str, request: Request, file: UploadFile = File(...), path: str = Form("")):
user = require_auth(request) user = require_auth(request)
if not site_exists(name): raise HTTPException(status_code=404, detail=f"Site '{name}' not found") if not site_exists(name):
raise HTTPException(status_code=404, detail=f"Site '{name}' not found")
site_dir = DEMO_ROOT / name site_dir = DEMO_ROOT / name
safe_path = path.strip("/").replace("..", "") safe_path = path.strip("/").replace("..", "")
dest_dir = site_dir / safe_path dest_dir = site_dir / safe_path
@@ -329,19 +408,39 @@ async def upload_file(name: str, request: Request, file: UploadFile = File(...),
_db.update(name, updated_at=datetime.now(timezone.utc).isoformat()) _db.update(name, updated_at=datetime.now(timezone.utc).isoformat())
return {"status": "uploaded", "name": name, "file": str(file_path.relative_to(site_dir)), "size": len(content)} return {"status": "uploaded", "name": name, "file": str(file_path.relative_to(site_dir)), "size": len(content)}
@app.delete("/api/sites/{name}/files") @app.delete("/api/sites/{name}/files")
def delete_file(name: str, request: Request, path: str = Form("")): def delete_file(name: str, request: Request, path: str = Form("")):
user = require_auth(request) user = require_auth(request)
if not site_exists(name): raise HTTPException(status_code=404) if not site_exists(name):
raise HTTPException(status_code=404)
safe_path = path.strip("/").replace("..", "") safe_path = path.strip("/").replace("..", "")
if not safe_path: raise HTTPException(status_code=400, detail="path required") if not safe_path:
raise HTTPException(status_code=400, detail="path required")
file_path = DEMO_ROOT / name / safe_path file_path = DEMO_ROOT / name / safe_path
if not file_path.exists(): raise HTTPException(status_code=404) if not file_path.exists():
if file_path.is_dir(): shutil.rmtree(file_path) raise HTTPException(status_code=404)
else: file_path.unlink() if file_path.is_dir():
shutil.rmtree(file_path)
else:
file_path.unlink()
return {"status": "deleted", "name": name, "file": safe_path} return {"status": "deleted", "name": name, "file": safe_path}
# ─── Routes ─────────────────────────────────────────
@app.get("/api/sites/{name}/preview")
def preview_site(name: str, request: Request):
user = require_auth(request)
if not site_exists(name):
raise HTTPException(status_code=404)
site_dir = DEMO_ROOT / name
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")
# ─── Web Routes ─────────────────────────────────────
@app.get("/admin", response_class=HTMLResponse) @app.get("/admin", response_class=HTMLResponse)
@app.get("/admin/", response_class=HTMLResponse) @app.get("/admin/", response_class=HTMLResponse)
@@ -351,6 +450,7 @@ def admin_ui(request: Request, path: str = ""):
return HTMLResponse(AUTH_REQUIRED_HTML) return HTMLResponse(AUTH_REQUIRED_HTML)
return HTMLResponse(UI_HTML) return HTMLResponse(UI_HTML)
@app.get("/") @app.get("/")
def index(request: Request): def index(request: Request):
user = check_auth(request) user = check_auth(request)