feat(demo-service): migrate metadata to SQLite

- New _db.py module with sqlite3-based persistence
- Auto-migrate from sites.json on first run
- All writes now use _db.insert/update/delete directly
- Sites default to draft (published: false) on creation

The sites.json file is kept as .json.migrated for safety
This commit is contained in:
Junv (via Hermes)
2026-06-14 12:43:50 +10:00
parent 492ed0c3e5
commit 950fa76eea
3 changed files with 169 additions and 43 deletions
+2 -1
View File
@@ -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
+149
View File
@@ -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()
+18 -42
View File
@@ -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):
<body><h1>🚀 {name}</h1></body>
</html>"""
(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):
<html><head><meta http-equiv="refresh" content="0;url=/admin"></head>
<body><p>Redirecting to <a href="/admin">Admin</a>...</p></body>
</html>""")