mirror of
https://github.com/wahyd4/home-docker.git
synced 2026-08-08 20:15:03 +10:00
demo-service: add three-tier visibility (disabled/internal/public)
- Replace old enabled/published fields with single visibility field:
- disabled → 404 for everyone (admin sees in admin UI only)
- internal → 192.168.1.x access without auth; external → 404
- public → anyone can access
- Admin users can see all sites via admin UI regardless of visibility
- Add /api/sites/{name}/visibility endpoint to change visibility
- Keep backward-compat enable/disable/publish/unpublish endpoints
- Database auto-migration from old enabled+published to new visibility
- NFS-safe sync_visibility hides/shows index.html per mode
This commit is contained in:
@@ -9,6 +9,8 @@ DB_PATH = Path(os.environ.get("DEMO_DB_PATH", "/data/demos/.meta/sites.db"))
|
||||
_lock = threading.Lock()
|
||||
_conn: Optional[sqlite3.Connection] = None
|
||||
|
||||
VALID_VISIBILITY = frozenset({"disabled", "internal", "public"})
|
||||
|
||||
|
||||
def get_conn() -> sqlite3.Connection:
|
||||
global _conn
|
||||
@@ -26,14 +28,54 @@ def _init_schema(conn: sqlite3.Connection):
|
||||
name TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
published INTEGER NOT NULL DEFAULT 0,
|
||||
visibility TEXT NOT NULL DEFAULT 'internal',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
# Migration: populate visibility from old enabled/published columns
|
||||
conn.execute("""
|
||||
UPDATE sites SET visibility = 'disabled' WHERE enabled = 0 AND visibility = 'internal'
|
||||
""")
|
||||
conn.execute("""
|
||||
UPDATE sites SET visibility = 'public' WHERE enabled = 1 AND published = 1 AND visibility = 'internal'
|
||||
""")
|
||||
# Future-proof: ensure no invalid visibility values
|
||||
conn.execute("""
|
||||
UPDATE sites SET visibility = 'internal' WHERE visibility NOT IN ('disabled', 'internal', 'public')
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ─── Converters ───────────────────────────
|
||||
|
||||
|
||||
def _visibility_from_old(enabled: bool, published: bool) -> str:
|
||||
"""Convert old enabled+published to new visibility."""
|
||||
if not enabled:
|
||||
return "disabled"
|
||||
if published:
|
||||
return "public"
|
||||
return "internal"
|
||||
|
||||
|
||||
def _row_to_dict(r) -> dict:
|
||||
"""Convert a SQLite Row to dict with both old and new fields."""
|
||||
return {
|
||||
"name": r["name"],
|
||||
"enabled": r["visibility"] != "disabled",
|
||||
"published": r["visibility"] == "public",
|
||||
"visibility": r["visibility"],
|
||||
"description": r["description"],
|
||||
"created_at": r["created_at"],
|
||||
"updated_at": r["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
# ─── Migration ────────────────────────────
|
||||
|
||||
|
||||
def migrate_from_json():
|
||||
"""One-time migration from old sites.json to SQLite DB."""
|
||||
json_path = DB_PATH.parent / "sites.json"
|
||||
@@ -47,20 +89,19 @@ def migrate_from_json():
|
||||
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
|
||||
published = info.get("published", True)
|
||||
enabled = info.get("enabled", True)
|
||||
visibility = _visibility_from_old(enabled, published)
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO sites
|
||||
(name, enabled, published, description, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(name, enabled, published,
|
||||
(name, enabled, published, visibility, description, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(name, 1 if enabled else 0, 1 if published else 0, visibility,
|
||||
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:
|
||||
@@ -68,50 +109,43 @@ def migrate_from_json():
|
||||
return False
|
||||
|
||||
|
||||
# ─── CRUD ────────────────────────────────
|
||||
|
||||
|
||||
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"
|
||||
"SELECT name, visibility, 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"],
|
||||
}
|
||||
sites[r["name"]] = _row_to_dict(r)
|
||||
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 = ?",
|
||||
"SELECT name, visibility, 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"],
|
||||
}
|
||||
return _row_to_dict(r)
|
||||
|
||||
|
||||
def insert(name: str, enabled: bool, published: bool, description: str, now: str):
|
||||
def insert(name: str, visibility: str, description: str, now: str):
|
||||
if visibility not in VALID_VISIBILITY:
|
||||
raise ValueError(f"Invalid visibility: {visibility}")
|
||||
enabled = 1 if visibility != "disabled" else 0
|
||||
published = 1 if visibility == "public" else 0
|
||||
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),
|
||||
"""INSERT INTO sites (name, enabled, published, visibility, description, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(name, enabled, published, visibility, description, now, now),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -119,18 +153,18 @@ def insert(name: str, enabled: bool, published: bool, description: str, now: str
|
||||
def update(name: str, **kwargs):
|
||||
if not kwargs:
|
||||
return
|
||||
# Handle visibility specially — sync enabled/published
|
||||
if "visibility" in kwargs:
|
||||
v = kwargs["visibility"]
|
||||
if v not in VALID_VISIBILITY:
|
||||
raise ValueError(f"Invalid visibility: {v}")
|
||||
kwargs["enabled"] = 1 if v != "disabled" else 0
|
||||
kwargs["published"] = 1 if v == "public" else 0
|
||||
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)
|
||||
cols.append(f"{k} = ?")
|
||||
vals.append(v)
|
||||
vals.append(name)
|
||||
with _lock:
|
||||
conn = get_conn()
|
||||
|
||||
+207
-107
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Demo Manager — FastAPI service for managing demo sites.
|
||||
Sites start as drafts (unpublished). Publishing makes them public.
|
||||
Pocket ID SSO + API key auth.
|
||||
Three visibility modes: disabled, internal (192.168.1.x), public.
|
||||
Pocket ID SSO + API key auth for management.
|
||||
"""
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
@@ -23,29 +24,49 @@ 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")
|
||||
app = FastAPI(title="Demo Manager", version="2.0.0")
|
||||
|
||||
# ─── Internal network detection ───────────
|
||||
INTERNAL_NETS = [ipaddress.ip_network("192.168.1.0/24")]
|
||||
|
||||
|
||||
def is_internal_ip(request: Request) -> bool:
|
||||
"""Check if the request originated from an internal network."""
|
||||
forwarded = request.headers.get("X-Forwarded-For", "")
|
||||
if forwarded:
|
||||
client_ip = forwarded.split(",")[0].strip()
|
||||
elif request.client:
|
||||
client_ip = request.client.host
|
||||
else:
|
||||
return False
|
||||
try:
|
||||
ip = ipaddress.ip_address(client_ip)
|
||||
return any(ip in net for net in INTERNAL_NETS)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
# ─── Models ─────────────────────────────────────────
|
||||
class SiteInfo(BaseModel):
|
||||
name: str
|
||||
enabled: bool = True
|
||||
published: bool = False
|
||||
visibility: str = "internal"
|
||||
description: str = ""
|
||||
created_at: str = ""
|
||||
updated_at: str = ""
|
||||
|
||||
|
||||
class SiteCreate(BaseModel):
|
||||
name: str
|
||||
visibility: str = "internal"
|
||||
description: str = ""
|
||||
published: bool = False
|
||||
|
||||
|
||||
class VisibilityUpdate(BaseModel):
|
||||
visibility: str
|
||||
|
||||
|
||||
# ─── Helpers ────────────────────────────────────────
|
||||
# Metadata is persisted in SQLite via _db module
|
||||
load_meta = _db.load_all
|
||||
|
||||
def save_meta(meta: dict):
|
||||
"""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", "")
|
||||
@@ -72,15 +93,17 @@ def check_auth(request: Request) -> Optional[str]:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def require_auth(request: Request) -> str:
|
||||
user = check_auth(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
def site_exists(name: str) -> bool:
|
||||
meta = load_meta()
|
||||
if name not in meta.get("sites", {}):
|
||||
info = _db.get(name)
|
||||
if not info:
|
||||
return False
|
||||
site_dir = DEMO_ROOT / name
|
||||
try:
|
||||
@@ -88,33 +111,40 @@ def site_exists(name: str) -> bool:
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
def is_site_visible(name: str) -> bool:
|
||||
meta = load_meta()
|
||||
info = meta.get("sites", {}).get(name, {})
|
||||
return info.get("enabled", True) and info.get("published", False)
|
||||
|
||||
def error_resp(status_code: int = 404) -> HTMLResponse:
|
||||
resp = HTMLResponse(AUTH_REQUIRED_HTML, status_code=status_code)
|
||||
resp.headers["Content-Disposition"] = "inline"
|
||||
resp.headers["Cache-Control"] = "no-store"
|
||||
return resp
|
||||
|
||||
|
||||
# ─── File Helpers (NFS safe — no rename) ─────────────
|
||||
def sync_visibility(name: str):
|
||||
meta = load_meta()
|
||||
info = meta.get("sites", {}).get(name, {})
|
||||
visible = info.get("enabled", True) and info.get("published", False)
|
||||
"""Sync the on-disk index.html based on visibility mode."""
|
||||
info = _db.get(name)
|
||||
if not info:
|
||||
return
|
||||
v = info["visibility"]
|
||||
site_dir = DEMO_ROOT / name
|
||||
real_index = site_dir / "index.html"
|
||||
draft_index = site_dir / ".index.html.draft"
|
||||
bak_index = site_dir / ".index.html.bak"
|
||||
|
||||
if visible:
|
||||
if v == "public":
|
||||
# Ensure public site has visible index.html
|
||||
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:
|
||||
# disabled or internal → hide index.html
|
||||
if real_index.exists():
|
||||
if not info.get("enabled", True):
|
||||
if v == "disabled":
|
||||
bak_index.write_bytes(real_index.read_bytes())
|
||||
real_index.unlink()
|
||||
elif not info.get("published", False):
|
||||
else: # internal
|
||||
draft_index.write_bytes(real_index.read_bytes())
|
||||
real_index.unlink()
|
||||
|
||||
@@ -122,37 +152,53 @@ def sync_visibility(name: str):
|
||||
if leftover.exists() and real_index.exists():
|
||||
leftover.unlink()
|
||||
|
||||
|
||||
# ─── API Routes ─────────────────────────────────────
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"status": "ok", "root": str(DEMO_ROOT)}
|
||||
|
||||
|
||||
@app.get("/api/error-page", response_class=HTMLResponse)
|
||||
def error_page():
|
||||
"""Serve the canvas error page for any HTTP error."""
|
||||
return HTMLResponse(AUTH_REQUIRED_HTML)
|
||||
|
||||
@app.get("/site-content", response_class=FileResponse)
|
||||
|
||||
# ─── Site content serving (access control) ──────────
|
||||
|
||||
@app.get("/site-content/{path:path}")
|
||||
async def site_content(path: str = "", request: Request = None):
|
||||
"""Serve site files. Drafts require auth."""
|
||||
"""Serve site files with visibility-based access control."""
|
||||
if not path:
|
||||
raise HTTPException(404)
|
||||
parts = path.split("/", 1)
|
||||
site_name = parts[0]
|
||||
sub = parts[1] if len(parts) > 1 else ""
|
||||
if not site_name or not site_exists(site_name):
|
||||
resp = HTMLResponse(AUTH_REQUIRED_HTML, status_code=404)
|
||||
resp.headers["Content-Disposition"] = "inline"
|
||||
resp.headers["Cache-Control"] = "no-store"
|
||||
return resp
|
||||
visible = is_site_visible(site_name)
|
||||
if not visible:
|
||||
if not check_auth(request):
|
||||
resp = HTMLResponse(AUTH_REQUIRED_HTML, status_code=401)
|
||||
resp.headers["Content-Disposition"] = "inline"
|
||||
resp.headers["Cache-Control"] = "no-store"
|
||||
return resp
|
||||
|
||||
info = _db.get(site_name)
|
||||
if not info:
|
||||
return error_resp(404)
|
||||
|
||||
visibility = info["visibility"]
|
||||
user = check_auth(request)
|
||||
|
||||
# ── Access control ──
|
||||
if visibility == "disabled":
|
||||
# No one can access disabled sites via public URL
|
||||
if not user:
|
||||
return error_resp(404)
|
||||
# Even auth'd users get 404 on disabled sites (use admin UI instead)
|
||||
return error_resp(404)
|
||||
|
||||
elif visibility == "internal":
|
||||
# Internal IPs can access without auth; external needs auth
|
||||
if not user and not is_internal_ip(request):
|
||||
# External + no auth → 404 (no login prompt)
|
||||
return error_resp(404)
|
||||
|
||||
# visibility == "public" → everyone can access
|
||||
# ── Serve file ──
|
||||
site_dir = DEMO_ROOT / site_name
|
||||
target = (site_dir / sub) if sub else (site_dir / "index.html")
|
||||
if not target.exists():
|
||||
@@ -163,50 +209,54 @@ async def site_content(path: str = "", request: Request = None):
|
||||
target = p
|
||||
break
|
||||
if not target.exists():
|
||||
resp = HTMLResponse(AUTH_REQUIRED_HTML, status_code=404)
|
||||
resp.headers["Content-Disposition"] = "inline"
|
||||
resp.headers["Cache-Control"] = "no-store"
|
||||
return resp
|
||||
return error_resp(404)
|
||||
if target.is_dir():
|
||||
target = target / "index.html"
|
||||
if not target.exists():
|
||||
resp = HTMLResponse(AUTH_REQUIRED_HTML, status_code=404)
|
||||
resp.headers["Content-Disposition"] = "inline"
|
||||
resp.headers["Cache-Control"] = "no-store"
|
||||
return resp
|
||||
# HTML files render inline; others (CSS, JS, images) are served as files
|
||||
# .draft and .bak are hidden HTML fallbacks (e.g. .index.html.draft)
|
||||
return error_resp(404)
|
||||
# HTML files render inline; others (CSS, JS, images) served as files
|
||||
if target.suffix == ".html" or target.suffix in (".draft", ".bak"):
|
||||
return HTMLResponse(target.read_text())
|
||||
resp = FileResponse(target)
|
||||
resp.headers["Content-Disposition"] = "inline"
|
||||
return resp
|
||||
|
||||
|
||||
# ─── Site management ────────────────────────────────
|
||||
|
||||
def _site_to_dict(name: str, info: dict) -> dict:
|
||||
"""Build the public site dict from DB info + filesystem."""
|
||||
site_dir = DEMO_ROOT / name
|
||||
file_count = 0
|
||||
if site_dir.is_dir():
|
||||
try:
|
||||
file_count = sum(1 for _ in site_dir.rglob("*") if _.is_file() and not _.name.startswith("."))
|
||||
except OSError:
|
||||
pass
|
||||
return {
|
||||
"name": name,
|
||||
"visibility": info["visibility"],
|
||||
"description": info["description"],
|
||||
"created_at": info["created_at"],
|
||||
"updated_at": info["updated_at"],
|
||||
"exists": True,
|
||||
"file_count": file_count,
|
||||
"url": f"https://demo.junv.cc/{name}" if info["visibility"] == "public" else None,
|
||||
"public": info["visibility"] == "public",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/sites")
|
||||
def list_sites(request: Request):
|
||||
user = require_auth(request)
|
||||
meta = load_meta()
|
||||
meta = _db.load_all()
|
||||
sites = []
|
||||
for name in sorted(meta.get("sites", {}).keys()):
|
||||
info = meta["sites"][name]
|
||||
site_dir = DEMO_ROOT / name
|
||||
file_count = 0
|
||||
if site_dir.is_dir():
|
||||
try:
|
||||
file_count = sum(1 for _ in site_dir.rglob("*") if _.is_file() and not _.name.startswith("."))
|
||||
except OSError:
|
||||
pass
|
||||
if "published" not in info:
|
||||
info["published"] = True
|
||||
sites.append({
|
||||
**info, "name": name,
|
||||
"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),
|
||||
})
|
||||
sites.append(_site_to_dict(name, info))
|
||||
return {"sites": sites, "user": user}
|
||||
|
||||
|
||||
@app.post("/api/sites")
|
||||
def create_site(body: SiteCreate, request: Request):
|
||||
user = require_auth(request)
|
||||
@@ -216,8 +266,6 @@ def create_site(body: SiteCreate, request: Request):
|
||||
if site_exists(name):
|
||||
raise HTTPException(status_code=409, detail=f"Site '{name}' already exists")
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
meta = load_meta()
|
||||
meta.setdefault("sites", {})
|
||||
site_dir = DEMO_ROOT / name
|
||||
site_dir.mkdir(parents=True, exist_ok=True)
|
||||
default_html = f"""<!DOCTYPE html>
|
||||
@@ -230,13 +278,15 @@ def create_site(body: SiteCreate, request: Request):
|
||||
(site_dir / "index.html").write_text(default_html)
|
||||
_db.insert(
|
||||
name=name,
|
||||
enabled=True,
|
||||
published=body.published,
|
||||
visibility=body.visibility if body.visibility in ("disabled", "internal", "public") else "internal",
|
||||
description=body.description,
|
||||
now=now,
|
||||
)
|
||||
sync_visibility(name)
|
||||
return site_info
|
||||
info = _db.get(name)
|
||||
assert info is not None
|
||||
return _site_to_dict(name, info)
|
||||
|
||||
|
||||
@app.delete("/api/sites/{name}")
|
||||
def delete_site(name: str, request: Request):
|
||||
@@ -245,61 +295,86 @@ def delete_site(name: str, request: Request):
|
||||
raise HTTPException(status_code=404, detail=f"Site '{name}' not found")
|
||||
_db.delete(name)
|
||||
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}
|
||||
|
||||
|
||||
# ── Visibility management ──────────────────────────
|
||||
|
||||
@app.post("/api/sites/{name}/visibility")
|
||||
def set_visibility(name: str, body: VisibilityUpdate, request: Request):
|
||||
"""Change site visibility: disabled | internal | public."""
|
||||
user = require_auth(request)
|
||||
if not site_exists(name):
|
||||
raise HTTPException(status_code=404)
|
||||
v = body.visibility
|
||||
if v not in ("disabled", "internal", "public"):
|
||||
raise HTTPException(status_code=400, detail="visibility must be: disabled, internal, or public")
|
||||
_db.update(name, visibility=v, updated_at=datetime.now(timezone.utc).isoformat())
|
||||
sync_visibility(name)
|
||||
info = _db.get(name)
|
||||
assert info is not None
|
||||
return _site_to_dict(name, info)
|
||||
|
||||
|
||||
# ── Backward-compat endpoints (deprecated) ─────────
|
||||
|
||||
@app.post("/api/sites/{name}/enable")
|
||||
def enable_site(name: str, request: Request):
|
||||
# Old "enable" → set visibility to last non-disabled value or public
|
||||
user = require_auth(request)
|
||||
if not site_exists(name): raise HTTPException(status_code=404)
|
||||
_db.update(name, enabled=True, updated_at=datetime.now(timezone.utc).isoformat())
|
||||
if not site_exists(name):
|
||||
raise HTTPException(status_code=404)
|
||||
info = _db.get(name)
|
||||
v = "public" if info and info.get("published") else "internal"
|
||||
_db.update(name, visibility=v, updated_at=datetime.now(timezone.utc).isoformat())
|
||||
sync_visibility(name)
|
||||
return {"status": "enabled", "name": name}
|
||||
return {"status": "enabled", "name": name, "visibility": v}
|
||||
|
||||
|
||||
@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)
|
||||
_db.update(name, enabled=False, updated_at=datetime.now(timezone.utc).isoformat())
|
||||
if not site_exists(name):
|
||||
raise HTTPException(status_code=404)
|
||||
_db.update(name, visibility="disabled", updated_at=datetime.now(timezone.utc).isoformat())
|
||||
sync_visibility(name)
|
||||
return {"status": "disabled", "name": name}
|
||||
|
||||
|
||||
@app.post("/api/sites/{name}/publish")
|
||||
def publish_site(name: str, request: Request):
|
||||
user = require_auth(request)
|
||||
if not site_exists(name): raise HTTPException(status_code=404)
|
||||
_db.update(name, published=True, updated_at=datetime.now(timezone.utc).isoformat())
|
||||
if not site_exists(name):
|
||||
raise HTTPException(status_code=404)
|
||||
_db.update(name, visibility="public", updated_at=datetime.now(timezone.utc).isoformat())
|
||||
sync_visibility(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):
|
||||
user = require_auth(request)
|
||||
if not site_exists(name): raise HTTPException(status_code=404)
|
||||
_db.update(name, published=False, updated_at=datetime.now(timezone.utc).isoformat())
|
||||
if not site_exists(name):
|
||||
raise HTTPException(status_code=404)
|
||||
_db.update(name, visibility="internal", updated_at=datetime.now(timezone.utc).isoformat())
|
||||
sync_visibility(name)
|
||||
return {"status": "unpublished", "name": name}
|
||||
|
||||
@app.get("/api/sites/{name}/preview")
|
||||
def preview_site(name: str, request: Request):
|
||||
user = require_auth(request)
|
||||
if not site_exists(name): raise HTTPException(status_code=404)
|
||||
site_dir = DEMO_ROOT / name
|
||||
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")
|
||||
|
||||
# ── File management ────────────────────────────────
|
||||
|
||||
@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)
|
||||
meta = load_meta()
|
||||
info = meta.get("sites", {}).get(name, {"name": name, "enabled": True, "published": False})
|
||||
if "published" not in info:
|
||||
info["published"] = True
|
||||
if not site_exists(name):
|
||||
raise HTTPException(status_code=404)
|
||||
info = _db.get(name)
|
||||
if not info:
|
||||
raise HTTPException(status_code=404)
|
||||
site_dir = DEMO_ROOT / name
|
||||
files = []
|
||||
if site_dir.is_dir():
|
||||
@@ -308,17 +383,21 @@ 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,
|
||||
"public": is_site_visible(name),
|
||||
"url": f"https://demo.junv.cc/{name}" if is_site_visible(name) else None,
|
||||
"site": info,
|
||||
"files": files,
|
||||
"public": info["visibility"] == "public",
|
||||
"url": f"https://demo.junv.cc/{name}" if info["visibility"] == "public" else None,
|
||||
}
|
||||
|
||||
|
||||
@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")
|
||||
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
|
||||
@@ -329,19 +408,39 @@ async def upload_file(name: str, request: Request, file: UploadFile = File(...),
|
||||
_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")
|
||||
def delete_file(name: str, request: Request, path: str = Form("")):
|
||||
user = require_auth(request)
|
||||
if not site_exists(name): raise HTTPException(status_code=404)
|
||||
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 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)
|
||||
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}
|
||||
|
||||
# ─── Routes ─────────────────────────────────────────
|
||||
|
||||
@app.get("/api/sites/{name}/preview")
|
||||
def preview_site(name: str, request: Request):
|
||||
user = require_auth(request)
|
||||
if not site_exists(name):
|
||||
raise HTTPException(status_code=404)
|
||||
site_dir = DEMO_ROOT / name
|
||||
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")
|
||||
|
||||
|
||||
# ─── Web Routes ─────────────────────────────────────
|
||||
|
||||
@app.get("/admin", response_class=HTMLResponse)
|
||||
@app.get("/admin/", response_class=HTMLResponse)
|
||||
@@ -351,6 +450,7 @@ def admin_ui(request: Request, path: str = ""):
|
||||
return HTMLResponse(AUTH_REQUIRED_HTML)
|
||||
return HTMLResponse(UI_HTML)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index(request: Request):
|
||||
user = check_auth(request)
|
||||
|
||||
Reference in New Issue
Block a user