mirror of
https://github.com/wahyd4/home-docker.git
synced 2026-08-09 04:15:52 +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()
|
_lock = threading.Lock()
|
||||||
_conn: Optional[sqlite3.Connection] = None
|
_conn: Optional[sqlite3.Connection] = None
|
||||||
|
|
||||||
|
VALID_VISIBILITY = frozenset({"disabled", "internal", "public"})
|
||||||
|
|
||||||
|
|
||||||
def get_conn() -> sqlite3.Connection:
|
def get_conn() -> sqlite3.Connection:
|
||||||
global _conn
|
global _conn
|
||||||
@@ -26,14 +28,54 @@ def _init_schema(conn: sqlite3.Connection):
|
|||||||
name TEXT PRIMARY KEY,
|
name TEXT PRIMARY KEY,
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
published INTEGER NOT NULL DEFAULT 0,
|
published INTEGER NOT NULL DEFAULT 0,
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'internal',
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_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()
|
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():
|
def migrate_from_json():
|
||||||
"""One-time migration from old sites.json to SQLite DB."""
|
"""One-time migration from old sites.json to SQLite DB."""
|
||||||
json_path = DB_PATH.parent / "sites.json"
|
json_path = DB_PATH.parent / "sites.json"
|
||||||
@@ -47,20 +89,19 @@ def migrate_from_json():
|
|||||||
try:
|
try:
|
||||||
data = json.loads(json_path.read_text())
|
data = json.loads(json_path.read_text())
|
||||||
for name, info in data.get("sites", {}).items():
|
for name, info in data.get("sites", {}).items():
|
||||||
# Backward compat: missing published defaults to True (existing sites stay public)
|
published = info.get("published", True)
|
||||||
published = 1 if info.get("published", True) else 0
|
enabled = info.get("enabled", True)
|
||||||
enabled = 1 if info.get("enabled", True) else 0
|
visibility = _visibility_from_old(enabled, published)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT OR REPLACE INTO sites
|
"""INSERT OR REPLACE INTO sites
|
||||||
(name, enabled, published, description, created_at, updated_at)
|
(name, enabled, published, visibility, description, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(name, enabled, published,
|
(name, 1 if enabled else 0, 1 if published else 0, visibility,
|
||||||
info.get("description", ""),
|
info.get("description", ""),
|
||||||
info.get("created_at", ""),
|
info.get("created_at", ""),
|
||||||
info.get("updated_at", "")),
|
info.get("updated_at", "")),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
# Rename old file so we don't re-migrate
|
|
||||||
json_path.rename(json_path.with_suffix(".json.migrated"))
|
json_path.rename(json_path.with_suffix(".json.migrated"))
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -68,50 +109,43 @@ def migrate_from_json():
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ─── CRUD ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def load_all() -> dict:
|
def load_all() -> dict:
|
||||||
"""Return sites as {name: info_dict} for backward compat."""
|
"""Return sites as {name: info_dict} for backward compat."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
rows = conn.execute(
|
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()
|
).fetchall()
|
||||||
sites = {}
|
sites = {}
|
||||||
for r in rows:
|
for r in rows:
|
||||||
sites[r["name"]] = {
|
sites[r["name"]] = _row_to_dict(r)
|
||||||
"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}
|
return {"sites": sites}
|
||||||
|
|
||||||
|
|
||||||
def get(name: str) -> Optional[dict]:
|
def get(name: str) -> Optional[dict]:
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
r = conn.execute(
|
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,),
|
(name,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not r:
|
if not r:
|
||||||
return None
|
return None
|
||||||
return {
|
return _row_to_dict(r)
|
||||||
"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):
|
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:
|
with _lock:
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT INTO sites (name, enabled, published, description, created_at, updated_at)
|
"""INSERT INTO sites (name, enabled, published, visibility, description, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(name, 1 if enabled else 0, 1 if published else 0, description, now, now),
|
(name, enabled, published, visibility, description, now, now),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
@@ -119,18 +153,18 @@ def insert(name: str, enabled: bool, published: bool, description: str, now: str
|
|||||||
def update(name: str, **kwargs):
|
def update(name: str, **kwargs):
|
||||||
if not kwargs:
|
if not kwargs:
|
||||||
return
|
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 = []
|
cols = []
|
||||||
vals = []
|
vals = []
|
||||||
for k, v in kwargs.items():
|
for k, v in kwargs.items():
|
||||||
if k == "enabled":
|
cols.append(f"{k} = ?")
|
||||||
cols.append("enabled = ?")
|
vals.append(v)
|
||||||
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)
|
vals.append(name)
|
||||||
with _lock:
|
with _lock:
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
|
|||||||
+207
-107
@@ -1,9 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Demo Manager — FastAPI service for managing demo sites.
|
Demo Manager — FastAPI service for managing demo sites.
|
||||||
Sites start as drafts (unpublished). Publishing makes them public.
|
Three visibility modes: disabled, internal (192.168.1.x), public.
|
||||||
Pocket ID SSO + API key auth.
|
Pocket ID SSO + API key auth for management.
|
||||||
"""
|
"""
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
@@ -23,29 +24,49 @@ from _config import API_KEY, OAUTH_PROXY
|
|||||||
from _loaders import AUTH_REQUIRED_HTML, UI_HTML
|
from _loaders import AUTH_REQUIRED_HTML, UI_HTML
|
||||||
import _db
|
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 ─────────────────────────────────────────
|
# ─── Models ─────────────────────────────────────────
|
||||||
class SiteInfo(BaseModel):
|
class SiteInfo(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
enabled: bool = True
|
visibility: str = "internal"
|
||||||
published: bool = False
|
|
||||||
description: str = ""
|
description: str = ""
|
||||||
created_at: str = ""
|
created_at: str = ""
|
||||||
updated_at: str = ""
|
updated_at: str = ""
|
||||||
|
|
||||||
|
|
||||||
class SiteCreate(BaseModel):
|
class SiteCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
visibility: str = "internal"
|
||||||
description: str = ""
|
description: str = ""
|
||||||
published: bool = False
|
|
||||||
|
|
||||||
|
class VisibilityUpdate(BaseModel):
|
||||||
|
visibility: str
|
||||||
|
|
||||||
|
|
||||||
# ─── Helpers ────────────────────────────────────────
|
# ─── 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]:
|
def check_auth(request: Request) -> Optional[str]:
|
||||||
auth_header = request.headers.get("Authorization", "")
|
auth_header = request.headers.get("Authorization", "")
|
||||||
@@ -72,15 +93,17 @@ def check_auth(request: Request) -> Optional[str]:
|
|||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def require_auth(request: Request) -> str:
|
def require_auth(request: Request) -> str:
|
||||||
user = check_auth(request)
|
user = check_auth(request)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=401, detail="Authentication required")
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def site_exists(name: str) -> bool:
|
def site_exists(name: str) -> bool:
|
||||||
meta = load_meta()
|
info = _db.get(name)
|
||||||
if name not in meta.get("sites", {}):
|
if not info:
|
||||||
return False
|
return False
|
||||||
site_dir = DEMO_ROOT / name
|
site_dir = DEMO_ROOT / name
|
||||||
try:
|
try:
|
||||||
@@ -88,33 +111,40 @@ def site_exists(name: str) -> bool:
|
|||||||
except OSError:
|
except OSError:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def is_site_visible(name: str) -> bool:
|
|
||||||
meta = load_meta()
|
def error_resp(status_code: int = 404) -> HTMLResponse:
|
||||||
info = meta.get("sites", {}).get(name, {})
|
resp = HTMLResponse(AUTH_REQUIRED_HTML, status_code=status_code)
|
||||||
return info.get("enabled", True) and info.get("published", False)
|
resp.headers["Content-Disposition"] = "inline"
|
||||||
|
resp.headers["Cache-Control"] = "no-store"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
# ─── File Helpers (NFS safe — no rename) ─────────────
|
# ─── File Helpers (NFS safe — no rename) ─────────────
|
||||||
def sync_visibility(name: str):
|
def sync_visibility(name: str):
|
||||||
meta = load_meta()
|
"""Sync the on-disk index.html based on visibility mode."""
|
||||||
info = meta.get("sites", {}).get(name, {})
|
info = _db.get(name)
|
||||||
visible = info.get("enabled", True) and info.get("published", False)
|
if not info:
|
||||||
|
return
|
||||||
|
v = info["visibility"]
|
||||||
site_dir = DEMO_ROOT / name
|
site_dir = DEMO_ROOT / name
|
||||||
real_index = site_dir / "index.html"
|
real_index = site_dir / "index.html"
|
||||||
draft_index = site_dir / ".index.html.draft"
|
draft_index = site_dir / ".index.html.draft"
|
||||||
bak_index = site_dir / ".index.html.bak"
|
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]:
|
for src in [draft_index, bak_index]:
|
||||||
if src.exists() and not real_index.exists():
|
if src.exists() and not real_index.exists():
|
||||||
real_index.write_bytes(src.read_bytes())
|
real_index.write_bytes(src.read_bytes())
|
||||||
src.unlink()
|
src.unlink()
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
|
# disabled or internal → hide index.html
|
||||||
if real_index.exists():
|
if real_index.exists():
|
||||||
if not info.get("enabled", True):
|
if v == "disabled":
|
||||||
bak_index.write_bytes(real_index.read_bytes())
|
bak_index.write_bytes(real_index.read_bytes())
|
||||||
real_index.unlink()
|
real_index.unlink()
|
||||||
elif not info.get("published", False):
|
else: # internal
|
||||||
draft_index.write_bytes(real_index.read_bytes())
|
draft_index.write_bytes(real_index.read_bytes())
|
||||||
real_index.unlink()
|
real_index.unlink()
|
||||||
|
|
||||||
@@ -122,37 +152,53 @@ def sync_visibility(name: str):
|
|||||||
if leftover.exists() and real_index.exists():
|
if leftover.exists() and real_index.exists():
|
||||||
leftover.unlink()
|
leftover.unlink()
|
||||||
|
|
||||||
|
|
||||||
# ─── API Routes ─────────────────────────────────────
|
# ─── API Routes ─────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def health():
|
def health():
|
||||||
return {"status": "ok", "root": str(DEMO_ROOT)}
|
return {"status": "ok", "root": str(DEMO_ROOT)}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/error-page", response_class=HTMLResponse)
|
@app.get("/api/error-page", response_class=HTMLResponse)
|
||||||
def error_page():
|
def error_page():
|
||||||
"""Serve the canvas error page for any HTTP error."""
|
|
||||||
return HTMLResponse(AUTH_REQUIRED_HTML)
|
return HTMLResponse(AUTH_REQUIRED_HTML)
|
||||||
|
|
||||||
@app.get("/site-content", response_class=FileResponse)
|
|
||||||
|
# ─── Site content serving (access control) ──────────
|
||||||
|
|
||||||
@app.get("/site-content/{path:path}")
|
@app.get("/site-content/{path:path}")
|
||||||
async def site_content(path: str = "", request: Request = None):
|
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:
|
if not path:
|
||||||
raise HTTPException(404)
|
raise HTTPException(404)
|
||||||
parts = path.split("/", 1)
|
parts = path.split("/", 1)
|
||||||
site_name = parts[0]
|
site_name = parts[0]
|
||||||
sub = parts[1] if len(parts) > 1 else ""
|
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)
|
info = _db.get(site_name)
|
||||||
resp.headers["Content-Disposition"] = "inline"
|
if not info:
|
||||||
resp.headers["Cache-Control"] = "no-store"
|
return error_resp(404)
|
||||||
return resp
|
|
||||||
visible = is_site_visible(site_name)
|
visibility = info["visibility"]
|
||||||
if not visible:
|
user = check_auth(request)
|
||||||
if not check_auth(request):
|
|
||||||
resp = HTMLResponse(AUTH_REQUIRED_HTML, status_code=401)
|
# ── Access control ──
|
||||||
resp.headers["Content-Disposition"] = "inline"
|
if visibility == "disabled":
|
||||||
resp.headers["Cache-Control"] = "no-store"
|
# No one can access disabled sites via public URL
|
||||||
return resp
|
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
|
site_dir = DEMO_ROOT / site_name
|
||||||
target = (site_dir / sub) if sub else (site_dir / "index.html")
|
target = (site_dir / sub) if sub else (site_dir / "index.html")
|
||||||
if not target.exists():
|
if not target.exists():
|
||||||
@@ -163,50 +209,54 @@ async def site_content(path: str = "", request: Request = None):
|
|||||||
target = p
|
target = p
|
||||||
break
|
break
|
||||||
if not target.exists():
|
if not target.exists():
|
||||||
resp = HTMLResponse(AUTH_REQUIRED_HTML, status_code=404)
|
return error_resp(404)
|
||||||
resp.headers["Content-Disposition"] = "inline"
|
|
||||||
resp.headers["Cache-Control"] = "no-store"
|
|
||||||
return resp
|
|
||||||
if target.is_dir():
|
if target.is_dir():
|
||||||
target = target / "index.html"
|
target = target / "index.html"
|
||||||
if not target.exists():
|
if not target.exists():
|
||||||
resp = HTMLResponse(AUTH_REQUIRED_HTML, status_code=404)
|
return error_resp(404)
|
||||||
resp.headers["Content-Disposition"] = "inline"
|
# HTML files render inline; others (CSS, JS, images) served as files
|
||||||
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)
|
|
||||||
if target.suffix == ".html" or target.suffix in (".draft", ".bak"):
|
if target.suffix == ".html" or target.suffix in (".draft", ".bak"):
|
||||||
return HTMLResponse(target.read_text())
|
return HTMLResponse(target.read_text())
|
||||||
resp = FileResponse(target)
|
resp = FileResponse(target)
|
||||||
resp.headers["Content-Disposition"] = "inline"
|
resp.headers["Content-Disposition"] = "inline"
|
||||||
return resp
|
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")
|
@app.get("/api/sites")
|
||||||
def list_sites(request: Request):
|
def list_sites(request: Request):
|
||||||
user = require_auth(request)
|
user = require_auth(request)
|
||||||
meta = load_meta()
|
meta = _db.load_all()
|
||||||
sites = []
|
sites = []
|
||||||
for name in sorted(meta.get("sites", {}).keys()):
|
for name in sorted(meta.get("sites", {}).keys()):
|
||||||
info = meta["sites"][name]
|
info = meta["sites"][name]
|
||||||
site_dir = DEMO_ROOT / name
|
sites.append(_site_to_dict(name, info))
|
||||||
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),
|
|
||||||
})
|
|
||||||
return {"sites": sites, "user": user}
|
return {"sites": sites, "user": user}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/sites")
|
@app.post("/api/sites")
|
||||||
def create_site(body: SiteCreate, request: Request):
|
def create_site(body: SiteCreate, request: Request):
|
||||||
user = require_auth(request)
|
user = require_auth(request)
|
||||||
@@ -216,8 +266,6 @@ def create_site(body: SiteCreate, request: Request):
|
|||||||
if site_exists(name):
|
if site_exists(name):
|
||||||
raise HTTPException(status_code=409, detail=f"Site '{name}' already exists")
|
raise HTTPException(status_code=409, detail=f"Site '{name}' already exists")
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
meta = load_meta()
|
|
||||||
meta.setdefault("sites", {})
|
|
||||||
site_dir = DEMO_ROOT / name
|
site_dir = DEMO_ROOT / name
|
||||||
site_dir.mkdir(parents=True, exist_ok=True)
|
site_dir.mkdir(parents=True, exist_ok=True)
|
||||||
default_html = f"""<!DOCTYPE html>
|
default_html = f"""<!DOCTYPE html>
|
||||||
@@ -230,13 +278,15 @@ def create_site(body: SiteCreate, request: Request):
|
|||||||
(site_dir / "index.html").write_text(default_html)
|
(site_dir / "index.html").write_text(default_html)
|
||||||
_db.insert(
|
_db.insert(
|
||||||
name=name,
|
name=name,
|
||||||
enabled=True,
|
visibility=body.visibility if body.visibility in ("disabled", "internal", "public") else "internal",
|
||||||
published=body.published,
|
|
||||||
description=body.description,
|
description=body.description,
|
||||||
now=now,
|
now=now,
|
||||||
)
|
)
|
||||||
sync_visibility(name)
|
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}")
|
@app.delete("/api/sites/{name}")
|
||||||
def delete_site(name: str, request: Request):
|
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")
|
raise HTTPException(status_code=404, detail=f"Site '{name}' not found")
|
||||||
_db.delete(name)
|
_db.delete(name)
|
||||||
site_dir = DEMO_ROOT / name
|
site_dir = DEMO_ROOT / name
|
||||||
try: shutil.rmtree(site_dir)
|
try:
|
||||||
except OSError: pass
|
shutil.rmtree(site_dir)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
return {"status": "deleted", "name": name}
|
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")
|
@app.post("/api/sites/{name}/enable")
|
||||||
def enable_site(name: str, request: Request):
|
def enable_site(name: str, request: Request):
|
||||||
|
# Old "enable" → set visibility to last non-disabled value or public
|
||||||
user = require_auth(request)
|
user = require_auth(request)
|
||||||
if not site_exists(name): raise HTTPException(status_code=404)
|
if not site_exists(name):
|
||||||
_db.update(name, enabled=True, updated_at=datetime.now(timezone.utc).isoformat())
|
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)
|
sync_visibility(name)
|
||||||
return {"status": "enabled", "name": name}
|
return {"status": "enabled", "name": name, "visibility": v}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/sites/{name}/disable")
|
@app.post("/api/sites/{name}/disable")
|
||||||
def disable_site(name: str, request: Request):
|
def disable_site(name: str, request: Request):
|
||||||
user = require_auth(request)
|
user = require_auth(request)
|
||||||
if not site_exists(name): raise HTTPException(status_code=404)
|
if not site_exists(name):
|
||||||
_db.update(name, enabled=False, updated_at=datetime.now(timezone.utc).isoformat())
|
raise HTTPException(status_code=404)
|
||||||
|
_db.update(name, visibility="disabled", updated_at=datetime.now(timezone.utc).isoformat())
|
||||||
sync_visibility(name)
|
sync_visibility(name)
|
||||||
return {"status": "disabled", "name": name}
|
return {"status": "disabled", "name": name}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/sites/{name}/publish")
|
@app.post("/api/sites/{name}/publish")
|
||||||
def publish_site(name: str, request: Request):
|
def publish_site(name: str, request: Request):
|
||||||
user = require_auth(request)
|
user = require_auth(request)
|
||||||
if not site_exists(name): raise HTTPException(status_code=404)
|
if not site_exists(name):
|
||||||
_db.update(name, published=True, updated_at=datetime.now(timezone.utc).isoformat())
|
raise HTTPException(status_code=404)
|
||||||
|
_db.update(name, visibility="public", updated_at=datetime.now(timezone.utc).isoformat())
|
||||||
sync_visibility(name)
|
sync_visibility(name)
|
||||||
return {"status": "published", "name": name, "url": f"https://demo.junv.cc/{name}"}
|
return {"status": "published", "name": name, "url": f"https://demo.junv.cc/{name}"}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/sites/{name}/unpublish")
|
@app.post("/api/sites/{name}/unpublish")
|
||||||
def unpublish_site(name: str, request: Request):
|
def unpublish_site(name: str, request: Request):
|
||||||
user = require_auth(request)
|
user = require_auth(request)
|
||||||
if not site_exists(name): raise HTTPException(status_code=404)
|
if not site_exists(name):
|
||||||
_db.update(name, published=False, updated_at=datetime.now(timezone.utc).isoformat())
|
raise HTTPException(status_code=404)
|
||||||
|
_db.update(name, visibility="internal", updated_at=datetime.now(timezone.utc).isoformat())
|
||||||
sync_visibility(name)
|
sync_visibility(name)
|
||||||
return {"status": "unpublished", "name": name}
|
return {"status": "unpublished", "name": name}
|
||||||
|
|
||||||
@app.get("/api/sites/{name}/preview")
|
|
||||||
def preview_site(name: str, request: Request):
|
# ── File management ────────────────────────────────
|
||||||
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")
|
|
||||||
|
|
||||||
@app.get("/api/sites/{name}")
|
@app.get("/api/sites/{name}")
|
||||||
def get_site(name: str, request: Request):
|
def get_site(name: str, request: Request):
|
||||||
require_auth(request)
|
require_auth(request)
|
||||||
if not site_exists(name): raise HTTPException(status_code=404)
|
if not site_exists(name):
|
||||||
meta = load_meta()
|
raise HTTPException(status_code=404)
|
||||||
info = meta.get("sites", {}).get(name, {"name": name, "enabled": True, "published": False})
|
info = _db.get(name)
|
||||||
if "published" not in info:
|
if not info:
|
||||||
info["published"] = True
|
raise HTTPException(status_code=404)
|
||||||
site_dir = DEMO_ROOT / name
|
site_dir = DEMO_ROOT / name
|
||||||
files = []
|
files = []
|
||||||
if site_dir.is_dir():
|
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("."):
|
if f.is_file() and not f.name.startswith("."):
|
||||||
rel = str(f.relative_to(site_dir))
|
rel = str(f.relative_to(site_dir))
|
||||||
files.append({"path": rel, "size": f.stat().st_size})
|
files.append({"path": rel, "size": f.stat().st_size})
|
||||||
except OSError: pass
|
except OSError:
|
||||||
|
pass
|
||||||
return {
|
return {
|
||||||
"site": info, "files": files,
|
"site": info,
|
||||||
"public": is_site_visible(name),
|
"files": files,
|
||||||
"url": f"https://demo.junv.cc/{name}" if is_site_visible(name) else None,
|
"public": info["visibility"] == "public",
|
||||||
|
"url": f"https://demo.junv.cc/{name}" if info["visibility"] == "public" else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/sites/{name}/files")
|
@app.post("/api/sites/{name}/files")
|
||||||
async def upload_file(name: str, request: Request, file: UploadFile = File(...), path: str = Form("")):
|
async def upload_file(name: str, request: Request, file: UploadFile = File(...), path: str = Form("")):
|
||||||
user = require_auth(request)
|
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
|
site_dir = DEMO_ROOT / name
|
||||||
safe_path = path.strip("/").replace("..", "")
|
safe_path = path.strip("/").replace("..", "")
|
||||||
dest_dir = site_dir / safe_path
|
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())
|
_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)}
|
return {"status": "uploaded", "name": name, "file": str(file_path.relative_to(site_dir)), "size": len(content)}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/sites/{name}/files")
|
@app.delete("/api/sites/{name}/files")
|
||||||
def delete_file(name: str, request: Request, path: str = Form("")):
|
def delete_file(name: str, request: Request, path: str = Form("")):
|
||||||
user = require_auth(request)
|
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("..", "")
|
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
|
file_path = DEMO_ROOT / name / safe_path
|
||||||
if not file_path.exists(): raise HTTPException(status_code=404)
|
if not file_path.exists():
|
||||||
if file_path.is_dir(): shutil.rmtree(file_path)
|
raise HTTPException(status_code=404)
|
||||||
else: file_path.unlink()
|
if file_path.is_dir():
|
||||||
|
shutil.rmtree(file_path)
|
||||||
|
else:
|
||||||
|
file_path.unlink()
|
||||||
return {"status": "deleted", "name": name, "file": safe_path}
|
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)
|
||||||
@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(AUTH_REQUIRED_HTML)
|
||||||
return HTMLResponse(UI_HTML)
|
return HTMLResponse(UI_HTML)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def index(request: Request):
|
def index(request: Request):
|
||||||
user = check_auth(request)
|
user = check_auth(request)
|
||||||
|
|||||||
Reference in New Issue
Block a user