diff --git a/home-apps/demo-service/Caddyfile b/home-apps/demo-service/Caddyfile index 06a6c61..489b3a6 100644 --- a/home-apps/demo-service/Caddyfile +++ b/home-apps/demo-service/Caddyfile @@ -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 } } diff --git a/home-apps/demo-service/service.py b/home-apps/demo-service/service.py index 2bead74..4b035b9 100644 --- a/home-apps/demo-service/service.py +++ b/home-apps/demo-service/service.py @@ -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""" + + # Create a default index.html (always starts as a real file) + default_html = f"""