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()
_conn: Optional[sqlite3.Connection] = None
VALID_VISIBILITY = frozenset({"disabled", "internal", "public"})
def get_conn() -> sqlite3.Connection:
global _conn
@@ -26,14 +28,54 @@ def _init_schema(conn: sqlite3.Connection):
name TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
published INTEGER NOT NULL DEFAULT 0,
visibility TEXT NOT NULL DEFAULT 'internal',
description TEXT NOT NULL DEFAULT '',
created_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()
# ─── 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():
"""One-time migration from old sites.json to SQLite DB."""
json_path = DB_PATH.parent / "sites.json"
@@ -47,20 +89,19 @@ def migrate_from_json():
try:
data = json.loads(json_path.read_text())
for name, info in data.get("sites", {}).items():
# Backward compat: missing published defaults to True (existing sites stay public)
published = 1 if info.get("published", True) else 0
enabled = 1 if info.get("enabled", True) else 0
published = info.get("published", True)
enabled = info.get("enabled", True)
visibility = _visibility_from_old(enabled, published)
conn.execute(
"""INSERT OR REPLACE INTO sites
(name, enabled, published, description, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)""",
(name, enabled, published,
(name, enabled, published, visibility, description, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(name, 1 if enabled else 0, 1 if published else 0, visibility,
info.get("description", ""),
info.get("created_at", ""),
info.get("updated_at", "")),
)
conn.commit()
# Rename old file so we don't re-migrate
json_path.rename(json_path.with_suffix(".json.migrated"))
return True
except Exception as e:
@@ -68,50 +109,43 @@ def migrate_from_json():
return False
# ─── CRUD ────────────────────────────────
def load_all() -> dict:
"""Return sites as {name: info_dict} for backward compat."""
conn = get_conn()
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()
sites = {}
for r in rows:
sites[r["name"]] = {
"name": r["name"],
"enabled": bool(r["enabled"]),
"published": bool(r["published"]),
"description": r["description"],
"created_at": r["created_at"],
"updated_at": r["updated_at"],
}
sites[r["name"]] = _row_to_dict(r)
return {"sites": sites}
def get(name: str) -> Optional[dict]:
conn = get_conn()
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,),
).fetchone()
if not r:
return None
return {
"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 _row_to_dict(r)
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:
conn = get_conn()
conn.execute(
"""INSERT INTO sites (name, enabled, published, description, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)""",
(name, 1 if enabled else 0, 1 if published else 0, description, now, now),
"""INSERT INTO sites (name, enabled, published, visibility, description, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(name, enabled, published, visibility, description, now, now),
)
conn.commit()
@@ -119,18 +153,18 @@ def insert(name: str, enabled: bool, published: bool, description: str, now: str
def update(name: str, **kwargs):
if not kwargs:
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 = []
vals = []
for k, v in kwargs.items():
if k == "enabled":
cols.append("enabled = ?")
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)
cols.append(f"{k} = ?")
vals.append(v)
vals.append(name)
with _lock:
conn = get_conn()
+207 -107
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env python3
"""
Demo Manager — FastAPI service for managing demo sites.
Sites start as drafts (unpublished). Publishing makes them public.
Pocket ID SSO + API key auth.
Three visibility modes: disabled, internal (192.168.1.x), public.
Pocket ID SSO + API key auth for management.
"""
import ipaddress
import json
import os
import shutil
@@ -23,29 +24,49 @@ from _config import API_KEY, OAUTH_PROXY
from _loaders import AUTH_REQUIRED_HTML, UI_HTML
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 ─────────────────────────────────────────
class SiteInfo(BaseModel):
name: str
enabled: bool = True
published: bool = False
visibility: str = "internal"
description: str = ""
created_at: str = ""
updated_at: str = ""
class SiteCreate(BaseModel):
name: str
visibility: str = "internal"
description: str = ""
published: bool = False
class VisibilityUpdate(BaseModel):
visibility: str
# ─── 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]:
auth_header = request.headers.get("Authorization", "")
@@ -72,15 +93,17 @@ def check_auth(request: Request) -> Optional[str]:
pass
return None
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:
meta = load_meta()
if name not in meta.get("sites", {}):
info = _db.get(name)
if not info:
return False
site_dir = DEMO_ROOT / name
try:
@@ -88,33 +111,40 @@ def site_exists(name: str) -> bool:
except OSError:
return True
def is_site_visible(name: str) -> bool:
meta = load_meta()
info = meta.get("sites", {}).get(name, {})
return info.get("enabled", True) and info.get("published", False)
def error_resp(status_code: int = 404) -> HTMLResponse:
resp = HTMLResponse(AUTH_REQUIRED_HTML, status_code=status_code)
resp.headers["Content-Disposition"] = "inline"
resp.headers["Cache-Control"] = "no-store"
return resp
# ─── File Helpers (NFS safe — no rename) ─────────────
def sync_visibility(name: str):
meta = load_meta()
info = meta.get("sites", {}).get(name, {})
visible = info.get("enabled", True) and info.get("published", False)
"""Sync the on-disk index.html based on visibility mode."""
info = _db.get(name)
if not info:
return
v = info["visibility"]
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:
if v == "public":
# Ensure public site has visible index.html
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:
# disabled or internal → hide index.html
if real_index.exists():
if not info.get("enabled", True):
if v == "disabled":
bak_index.write_bytes(real_index.read_bytes())
real_index.unlink()
elif not info.get("published", False):
else: # internal
draft_index.write_bytes(real_index.read_bytes())
real_index.unlink()
@@ -122,37 +152,53 @@ def sync_visibility(name: str):
if leftover.exists() and real_index.exists():
leftover.unlink()
# ─── API Routes ─────────────────────────────────────
@app.get("/api/health")
def health():
return {"status": "ok", "root": str(DEMO_ROOT)}
@app.get("/api/error-page", response_class=HTMLResponse)
def error_page():
"""Serve the canvas error page for any HTTP error."""
return HTMLResponse(AUTH_REQUIRED_HTML)
@app.get("/site-content", response_class=FileResponse)
# ─── Site content serving (access control) ──────────
@app.get("/site-content/{path:path}")
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:
raise HTTPException(404)
parts = path.split("/", 1)
site_name = parts[0]
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)
resp.headers["Content-Disposition"] = "inline"
resp.headers["Cache-Control"] = "no-store"
return resp
visible = is_site_visible(site_name)
if not visible:
if not check_auth(request):
resp = HTMLResponse(AUTH_REQUIRED_HTML, status_code=401)
resp.headers["Content-Disposition"] = "inline"
resp.headers["Cache-Control"] = "no-store"
return resp
info = _db.get(site_name)
if not info:
return error_resp(404)
visibility = info["visibility"]
user = check_auth(request)
# ── Access control ──
if visibility == "disabled":
# No one can access disabled sites via public URL
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
target = (site_dir / sub) if sub else (site_dir / "index.html")
if not target.exists():
@@ -163,50 +209,54 @@ async def site_content(path: str = "", request: Request = None):
target = p
break
if not target.exists():
resp = HTMLResponse(AUTH_REQUIRED_HTML, status_code=404)
resp.headers["Content-Disposition"] = "inline"
resp.headers["Cache-Control"] = "no-store"
return resp
return error_resp(404)
if target.is_dir():
target = target / "index.html"
if not target.exists():
resp = HTMLResponse(AUTH_REQUIRED_HTML, status_code=404)
resp.headers["Content-Disposition"] = "inline"
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)
return error_resp(404)
# HTML files render inline; others (CSS, JS, images) served as files
if target.suffix == ".html" or target.suffix in (".draft", ".bak"):
return HTMLResponse(target.read_text())
resp = FileResponse(target)
resp.headers["Content-Disposition"] = "inline"
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")
def list_sites(request: Request):
user = require_auth(request)
meta = load_meta()
meta = _db.load_all()
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():
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),
})
sites.append(_site_to_dict(name, info))
return {"sites": sites, "user": user}
@app.post("/api/sites")
def create_site(body: SiteCreate, request: Request):
user = require_auth(request)
@@ -216,8 +266,6 @@ def create_site(body: SiteCreate, request: Request):
if site_exists(name):
raise HTTPException(status_code=409, detail=f"Site '{name}' already exists")
now = datetime.now(timezone.utc).isoformat()
meta = load_meta()
meta.setdefault("sites", {})
site_dir = DEMO_ROOT / name
site_dir.mkdir(parents=True, exist_ok=True)
default_html = f"""<!DOCTYPE html>
@@ -230,13 +278,15 @@ def create_site(body: SiteCreate, request: Request):
(site_dir / "index.html").write_text(default_html)
_db.insert(
name=name,
enabled=True,
published=body.published,
visibility=body.visibility if body.visibility in ("disabled", "internal", "public") else "internal",
description=body.description,
now=now,
)
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}")
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")
_db.delete(name)
site_dir = DEMO_ROOT / name
try: shutil.rmtree(site_dir)
except OSError: pass
try:
shutil.rmtree(site_dir)
except OSError:
pass
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")
def enable_site(name: str, request: Request):
# Old "enable" → set visibility to last non-disabled value or public
user = require_auth(request)
if not site_exists(name): raise HTTPException(status_code=404)
_db.update(name, enabled=True, updated_at=datetime.now(timezone.utc).isoformat())
if not site_exists(name):
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)
return {"status": "enabled", "name": name}
return {"status": "enabled", "name": name, "visibility": v}
@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)
_db.update(name, enabled=False, updated_at=datetime.now(timezone.utc).isoformat())
if not site_exists(name):
raise HTTPException(status_code=404)
_db.update(name, visibility="disabled", updated_at=datetime.now(timezone.utc).isoformat())
sync_visibility(name)
return {"status": "disabled", "name": name}
@app.post("/api/sites/{name}/publish")
def publish_site(name: str, request: Request):
user = require_auth(request)
if not site_exists(name): raise HTTPException(status_code=404)
_db.update(name, published=True, updated_at=datetime.now(timezone.utc).isoformat())
if not site_exists(name):
raise HTTPException(status_code=404)
_db.update(name, visibility="public", updated_at=datetime.now(timezone.utc).isoformat())
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):
user = require_auth(request)
if not site_exists(name): raise HTTPException(status_code=404)
_db.update(name, published=False, updated_at=datetime.now(timezone.utc).isoformat())
if not site_exists(name):
raise HTTPException(status_code=404)
_db.update(name, visibility="internal", updated_at=datetime.now(timezone.utc).isoformat())
sync_visibility(name)
return {"status": "unpublished", "name": name}
@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")
# ── File management ────────────────────────────────
@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)
meta = load_meta()
info = meta.get("sites", {}).get(name, {"name": name, "enabled": True, "published": False})
if "published" not in info:
info["published"] = True
if not site_exists(name):
raise HTTPException(status_code=404)
info = _db.get(name)
if not info:
raise HTTPException(status_code=404)
site_dir = DEMO_ROOT / name
files = []
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("."):
rel = str(f.relative_to(site_dir))
files.append({"path": rel, "size": f.stat().st_size})
except OSError: pass
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,
"site": info,
"files": files,
"public": info["visibility"] == "public",
"url": f"https://demo.junv.cc/{name}" if info["visibility"] == "public" else None,
}
@app.post("/api/sites/{name}/files")
async def upload_file(name: str, request: Request, file: UploadFile = File(...), path: str = Form("")):
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
safe_path = path.strip("/").replace("..", "")
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())
return {"status": "uploaded", "name": name, "file": str(file_path.relative_to(site_dir)), "size": len(content)}
@app.delete("/api/sites/{name}/files")
def delete_file(name: str, request: Request, path: str = Form("")):
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("..", "")
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
if not file_path.exists(): raise HTTPException(status_code=404)
if file_path.is_dir(): shutil.rmtree(file_path)
else: file_path.unlink()
if not file_path.exists():
raise HTTPException(status_code=404)
if file_path.is_dir():
shutil.rmtree(file_path)
else:
file_path.unlink()
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)
@@ -351,6 +450,7 @@ def admin_ui(request: Request, path: str = ""):
return HTMLResponse(AUTH_REQUIRED_HTML)
return HTMLResponse(UI_HTML)
@app.get("/")
def index(request: Request):
user = check_auth(request)