From 4dce2be3d43c8d7be1f12a026d6e5804f942c8b6 Mon Sep 17 00:00:00 2001 From: "Junv (via Hermes)" Date: Sun, 14 Jun 2026 11:10:25 +1000 Subject: [PATCH] feat(demo-service): add publish/unpublish draft system with preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sites now start as drafts (published: false) — hidden from public - Publish endpoint makes site live at demo.junv.cc/ - 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 --- home-apps/demo-service/Caddyfile | 9 + home-apps/demo-service/service.py | 597 ++++++++++++++++-------------- 2 files changed, 319 insertions(+), 287 deletions(-) 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""" {name}

🚀 {name}

-""") - +""""" + (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 = """ +UI_HTML = r""" @@ -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} -
-

🧪 Demo Manager

-
Loading...
-
+

🧪 Demo Manager

Loading...
-

➕ Create Demo Site

+

➕ New Demo Site — drafts are hidden until published

-
- - -
-
- - -
+
+
+
- -
-

📂 My Demo Sites

-
Loading...
-
-
- - + +
- @@ -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("""

Redirecting to Admin...