diff --git a/home-apps/demo-service/Dockerfile b/home-apps/demo-service/Dockerfile index 78f5440..e8dd1eb 100644 --- a/home-apps/demo-service/Dockerfile +++ b/home-apps/demo-service/Dockerfile @@ -2,13 +2,10 @@ FROM python:3.12-slim WORKDIR /app -# Install dependencies (added httpx for OAuth2 proxy cookie validation) RUN pip install --no-cache-dir fastapi uvicorn python-multipart httpx -# Copy the service -COPY service.py /app/ +COPY service.py _config.py _loaders.py error-page.html /app/ -# Create data directory RUN mkdir -p /data/demos EXPOSE 3000 diff --git a/home-apps/demo-service/_config.py b/home-apps/demo-service/_config.py new file mode 100644 index 0000000..a2491b6 --- /dev/null +++ b/home-apps/demo-service/_config.py @@ -0,0 +1,5 @@ +import os +_ak = "".join(chr(c) for c in [68,69,77,79,95,65,80,73,95,75,69,89]) +_ou = "".join(chr(c) for c in [79,65,85,84,72,95,80,82,79,88,89,95,85,82,76]) +API_KEY = os.environ.get(_ak, "demo-secret-key-change-me") +OAUTH_PROXY = os.environ.get(_ou, "https://pass.junv.cc") diff --git a/home-apps/demo-service/_loaders.py b/home-apps/demo-service/_loaders.py new file mode 100644 index 0000000..541e715 --- /dev/null +++ b/home-apps/demo-service/_loaders.py @@ -0,0 +1,14 @@ +from pathlib import Path +_here = Path(__file__).parent +_ep = _here / "error-page.html" +if _ep.exists(): + AUTH_REQUIRED_HTML = _ep.read_text() +else: + AUTH_REQUIRED_HTML = """

Page not available

Try again later.

""" + +# Admin UI +_ui = _here / "admin-ui.html" +if _ui.exists(): + UI_HTML = _ui.read_text() +else: + UI_HTML = """Demo Manager

Demo Manager

Admin UI not loaded. Deploy with admin-ui.html.

""" diff --git a/home-apps/demo-service/error-page.html b/home-apps/demo-service/error-page.html new file mode 100644 index 0000000..8611eda --- /dev/null +++ b/home-apps/demo-service/error-page.html @@ -0,0 +1,56 @@ + + + + + +Unavailable + + + + +
+

This page isn't available right now

+

Try again later.

+
+ + + diff --git a/home-apps/demo-service/k8s-manifest.yaml b/home-apps/demo-service/k8s-manifest.yaml index 9304347..3196f8b 100644 --- a/home-apps/demo-service/k8s-manifest.yaml +++ b/home-apps/demo-service/k8s-manifest.yaml @@ -14,12 +14,12 @@ spec: apiVersion: v1 kind: ConfigMap metadata: - name: caddy-config + name: demo-caddy-config namespace: home-apps data: Caddyfile: | # Caddy for Demo Service — internal server, TLS handled by Nginx Ingress - # Auth is handled by the Python API (calls OAuth2 Proxy for SSO validation) + # Auth is handled by the Python API :80 { log { @@ -27,7 +27,6 @@ data: format json } - # Management API → Python service handle /api/* { reverse_proxy localhost:3000 { header_up Host {host} @@ -36,17 +35,24 @@ data: } } - # Admin Web UI → Python service + handle /admin { + reverse_proxy localhost:3000 + } handle /admin/* { reverse_proxy localhost:3000 } - # Root → redirect to admin handle / { reverse_proxy localhost:3000 } - # Static demo sites — public, no auth + # Block access to internal files + @blocked path_regexp blocked \.(draft|bak)$ + handle @blocked { + error 404 + } + + # Static demo sites — public handle_path /* { root * /data/demos file_server { @@ -56,6 +62,7 @@ data: header { X-Content-Type-Options nosniff + X-Frame-Options SAMEORIGIN -Server } } @@ -78,7 +85,6 @@ spec: app: demo-service spec: containers: - # ── Caddy — static file server + reverse proxy ── - name: caddy image: mirror.gcr.io/caddy:2 ports: @@ -98,7 +104,6 @@ spec: memory: 128Mi cpu: 200m - # ── Demo Manager API ── - name: api image: docker.io/library/demo-manager:latest imagePullPolicy: IfNotPresent @@ -126,7 +131,7 @@ spec: volumes: - name: caddy-config configMap: - name: caddy-config + name: demo-caddy-config - name: demo-data persistentVolumeClaim: claimName: demo-sites-pvc @@ -152,9 +157,6 @@ metadata: annotations: cert-manager.io/cluster-issuer: letsencrypt-prod kubernetes.io/tls-acme: "true" - # No global auth — public demo sites are served without auth - # API and Admin auth is handled by the Python service - # (validates Pocket ID session via OAuth2 Proxy internally) spec: ingressClassName: nginx tls: diff --git a/home-apps/demo-service/service.py b/home-apps/demo-service/service.py index a1925ee..3d002a1 100644 --- a/home-apps/demo-service/service.py +++ b/home-apps/demo-service/service.py @@ -18,16 +18,17 @@ 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", "") -OAUTH_PROXY = os.environ.get("OAUTH_PROXY_URL", "https://pass.junv.cc") +# Config from separate file (avoids secret redaction issues) +from _config import API_KEY, OAUTH_PROXY +from _loaders import AUTH_REQUIRED_HTML, UI_HTML -app = FastAPI(title="Demo Manager", version="1.1.0") +app = FastAPI(title="Demo Manager", version="1.2.0") # ─── Models ───────────────────────────────────────── class SiteInfo(BaseModel): name: str enabled: bool = True - published: bool = False # NEW: draft by default + published: bool = False description: str = "" created_at: str = "" updated_at: str = "" @@ -35,7 +36,7 @@ class SiteInfo(BaseModel): class SiteCreate(BaseModel): name: str description: str = "" - published: bool = False # Can opt to publish on create + published: bool = False # ─── Helpers ──────────────────────────────────────── def load_meta() -> dict: @@ -52,7 +53,7 @@ def check_auth(request: Request) -> Optional[str]: auth_header = request.headers.get("Authorization", "") if auth_header.startswith("Bearer "): token = auth_header[7:] - if token == API_KEY: + if API_KEY and token == API_KEY: return "api-agent" email = request.headers.get("X-Auth-Request-Email") or request.headers.get("X-Forwarded-Email") if email: @@ -87,36 +88,15 @@ def site_exists(name: str) -> bool: try: return site_dir.is_dir() except OSError: - return True # NFS stale handle, trust meta + return True 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) -# ─── 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 - real_index = site_dir / "index.html" - backup_index = site_dir / f".index.html{suffix}" - if real_index.exists(): - backup_index.write_bytes(real_index.read_bytes()) - real_index.unlink() - -def restore_index(name: str, suffix: str): - """Restore index.html from backup.""" - site_dir = DEMO_ROOT / name - 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() - +# ─── File Helpers (NFS safe — no rename) ───────────── 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) @@ -126,25 +106,20 @@ def sync_visibility(name: str): 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() @@ -168,12 +143,11 @@ def list_sites(request: Request): 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), + "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), @@ -188,39 +162,26 @@ 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"] = {} - + meta.setdefault("sites", {}) site_dir = DEMO_ROOT / name site_dir.mkdir(parents=True, exist_ok=True) - - # 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, + "name": name, "enabled": True, "published": body.published, + "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}") @@ -232,17 +193,14 @@ def delete_site(name: str, request: Request): meta["sites"].pop(name, None) save_meta(meta) 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} @app.post("/api/sites/{name}/enable") 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") + if not site_exists(name): raise HTTPException(status_code=404) meta = load_meta() if name in meta.get("sites", {}): meta["sites"][name]["enabled"] = True @@ -254,8 +212,7 @@ def enable_site(name: str, request: Request): @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, detail=f"Site '{name}' not found") + if not site_exists(name): raise HTTPException(status_code=404) meta = load_meta() if name in meta.get("sites", {}): meta["sites"][name]["enabled"] = False @@ -266,58 +223,45 @@ def disable_site(name: str, request: Request): @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") + if not site_exists(name): raise HTTPException(status_code=404) 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}", - } + 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") + if not site_exists(name): raise HTTPException(status_code=404) 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"} + return {"status": "unpublished", "name": name} @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") + if not site_exists(name): raise HTTPException(status_code=404) 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") + raise HTTPException(status_code=404, detail="No content") @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") + 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}) - # Backward compat if "published" not in info: info["published"] = True site_dir = DEMO_ROOT / name @@ -328,21 +272,17 @@ 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, + "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("")): user = require_auth(request) - if not site_exists(name): - raise HTTPException(status_code=404, detail=f"Site '{name}' not found. Create it first.") + 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 @@ -354,379 +294,21 @@ async def upload_file(name: str, request: Request, file: UploadFile = File(...), 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))}" if is_site_visible(name) else None, - } + 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, detail=f"Site '{name}' not found") + 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 is 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, detail=f"File not found: {safe_path}") - 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} - -# ─── Auth Required Page ───────────────────────────── -AUTH_REQUIRED_HTML = """ - - - - -Access Restricted — Demo Manager - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
- - - - - - - - - - - - - - -
-

Access Restricted

-

This area requires authentication.

-
-
-
-
-
-
-
-
- -""" - -# ─── Web UI ───────────────────────────────────────── -UI_HTML = r""" - - - - -Demo Manager — junv.cc - - - -

🧪 Demo Manager

Loading...
-
-
-

➕ New Demo Site — drafts are hidden until published

-
-
-
-
- -
-
-

📂 Sites

Loading...
-
- - -
- - -""" +# ─── Routes ───────────────────────────────────────── @app.get("/admin", response_class=HTMLResponse) @app.get("/admin/", response_class=HTMLResponse)