mirror of
https://github.com/wahyd4/home-docker.git
synced 2026-08-09 04:15:52 +10:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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>""")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user