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 ManagerDemo 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}
-"""""
+