diff --git a/home-apps/demo-service/_db.py b/home-apps/demo-service/_db.py index acb4738..454c9f0 100644 --- a/home-apps/demo-service/_db.py +++ b/home-apps/demo-service/_db.py @@ -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() diff --git a/home-apps/demo-service/service.py b/home-apps/demo-service/service.py index d366306..d327071 100644 --- a/home-apps/demo-service/service.py +++ b/home-apps/demo-service/service.py @@ -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""" @@ -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)