diff --git a/home-apps/demo-service/Dockerfile b/home-apps/demo-service/Dockerfile index 2b2d079..c0e11c0 100644 --- a/home-apps/demo-service/Dockerfile +++ b/home-apps/demo-service/Dockerfile @@ -4,7 +4,8 @@ WORKDIR /app RUN pip install --no-cache-dir fastapi uvicorn python-multipart httpx -COPY service.py _config.py _loaders.py error-page.html admin-ui.html /app/ +# Note: python:3.12-slim includes sqlite3 by default +COPY service.py _config.py _loaders.py _db.py error-page.html admin-ui.html /app/ RUN mkdir -p /data/demos diff --git a/home-apps/demo-service/_db.py b/home-apps/demo-service/_db.py new file mode 100644 index 0000000..acb4738 --- /dev/null +++ b/home-apps/demo-service/_db.py @@ -0,0 +1,149 @@ +import os +import json +import sqlite3 +import threading +from pathlib import Path +from typing import Optional + +DB_PATH = Path(os.environ.get("DEMO_DB_PATH", "/data/demos/.meta/sites.db")) +_lock = threading.Lock() +_conn: Optional[sqlite3.Connection] = None + + +def get_conn() -> sqlite3.Connection: + global _conn + if _conn is None: + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + _conn = sqlite3.connect(str(DB_PATH), check_same_thread=False) + _conn.row_factory = sqlite3.Row + _init_schema(_conn) + return _conn + + +def _init_schema(conn: sqlite3.Connection): + conn.execute(""" + CREATE TABLE IF NOT EXISTS sites ( + name TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 1, + published INTEGER NOT NULL DEFAULT 0, + description TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """) + conn.commit() + + +def migrate_from_json(): + """One-time migration from old sites.json to SQLite DB.""" + json_path = DB_PATH.parent / "sites.json" + if not json_path.exists(): + return False + conn = get_conn() + cur = conn.execute("SELECT COUNT(*) FROM sites") + if cur.fetchone()[0] > 0: + # Already migrated + return False + 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 + conn.execute( + """INSERT OR REPLACE INTO sites + (name, enabled, published, description, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)""", + (name, enabled, published, + 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: + print(f"Migration failed: {e}") + return False + + +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" + ).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"], + } + 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 = ?", + (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"], + } + + +def insert(name: str, enabled: bool, published: bool, description: str, now: str): + 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), + ) + conn.commit() + + +def update(name: str, **kwargs): + if not kwargs: + return + 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) + vals.append(name) + with _lock: + conn = get_conn() + conn.execute(f"UPDATE sites SET {', '.join(cols)} WHERE name = ?", vals) + conn.commit() + + +def delete(name: str): + with _lock: + conn = get_conn() + conn.execute("DELETE FROM sites WHERE name = ?", (name,)) + conn.commit() + + +# Run migration on import +migrate_from_json() diff --git a/home-apps/demo-service/service.py b/home-apps/demo-service/service.py index f37cc62..2db544f 100644 --- a/home-apps/demo-service/service.py +++ b/home-apps/demo-service/service.py @@ -21,6 +21,7 @@ META_FILE = DEMO_ROOT / ".meta" / "sites.json" # Config from separate file (avoids secret redaction issues) 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") @@ -39,15 +40,12 @@ class SiteCreate(BaseModel): published: bool = False # ─── Helpers ──────────────────────────────────────── -def load_meta() -> dict: - META_FILE.parent.mkdir(parents=True, exist_ok=True) - if META_FILE.exists(): - return json.loads(META_FILE.read_text()) - return {"sites": {}} +# Metadata is persisted in SQLite via _db module +load_meta = _db.load_all def save_meta(meta: dict): - META_FILE.parent.mkdir(parents=True, exist_ok=True) - META_FILE.write_text(json.dumps(meta, indent=2)) + """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", "") @@ -229,12 +227,13 @@ def create_site(body: SiteCreate, request: Request):

🚀 {name}

""" (site_dir / "index.html").write_text(default_html) - site_info = { - "name": name, "enabled": True, "published": body.published, - "description": body.description, "created_at": now, "updated_at": now, - } - meta["sites"][name] = site_info - save_meta(meta) + _db.insert( + name=name, + enabled=True, + published=body.published, + description=body.description, + now=now, + ) sync_visibility(name) return site_info @@ -243,9 +242,7 @@ 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") - meta = load_meta() - meta["sites"].pop(name, None) - save_meta(meta) + _db.delete(name) site_dir = DEMO_ROOT / name try: shutil.rmtree(site_dir) except OSError: pass @@ -255,11 +252,7 @@ def delete_site(name: str, request: Request): def enable_site(name: str, request: Request): user = require_auth(request) if not site_exists(name): raise HTTPException(status_code=404) - 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) + _db.update(name, enabled=True, updated_at=datetime.now(timezone.utc).isoformat()) sync_visibility(name) return {"status": "enabled", "name": name} @@ -267,11 +260,7 @@ def enable_site(name: str, request: Request): def disable_site(name: str, request: Request): user = require_auth(request) if not site_exists(name): raise HTTPException(status_code=404) - 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) + _db.update(name, enabled=False, updated_at=datetime.now(timezone.utc).isoformat()) sync_visibility(name) return {"status": "disabled", "name": name} @@ -279,11 +268,7 @@ def disable_site(name: str, request: Request): def publish_site(name: str, request: Request): user = require_auth(request) 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) + _db.update(name, published=True, updated_at=datetime.now(timezone.utc).isoformat()) sync_visibility(name) return {"status": "published", "name": name, "url": f"https://demo.junv.cc/{name}"} @@ -291,11 +276,7 @@ def publish_site(name: str, request: Request): def unpublish_site(name: str, request: Request): user = require_auth(request) 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) + _db.update(name, published=False, updated_at=datetime.now(timezone.utc).isoformat()) sync_visibility(name) return {"status": "unpublished", "name": name} @@ -344,10 +325,7 @@ async def upload_file(name: str, request: Request, file: UploadFile = File(...), file_path = dest_dir / file.filename content = await file.read() file_path.write_bytes(content) - meta = load_meta() - if name in meta.get("sites", {}): - meta["sites"][name]["updated_at"] = datetime.now(timezone.utc).isoformat() - save_meta(meta) + _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") @@ -378,5 +356,3 @@ def index(request: Request):

Redirecting to Admin...

""") - -