From cc06d72b57819de9bbf95b1368d7cc714a25b648 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Sat, 11 Jul 2026 13:14:40 +0200 Subject: [PATCH] feat(metablogizer): publisher wizard endpoints (wizard/export/import) Co-Authored-By: Gerald KERMA --- packages/secubox-metablogizer/api/main.py | 2 + .../api/routers/__init__.py | 1 - .../api/routers/publish.py | 84 +++++++++++++++++++ .../api/tests/test_publish_router.py | 53 ++++++++++++ 4 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 packages/secubox-metablogizer/api/routers/publish.py create mode 100644 packages/secubox-metablogizer/api/tests/test_publish_router.py diff --git a/packages/secubox-metablogizer/api/main.py b/packages/secubox-metablogizer/api/main.py index dc4ad215..ede5dce4 100644 --- a/packages/secubox-metablogizer/api/main.py +++ b/packages/secubox-metablogizer/api/main.py @@ -52,6 +52,8 @@ from webhook import ( verify_signature, _record_deploy, ) +from routers.publish import router as publish_router +app.include_router(publish_router) logger = logging.getLogger("metablogizer") diff --git a/packages/secubox-metablogizer/api/routers/__init__.py b/packages/secubox-metablogizer/api/routers/__init__.py index 7c35c7af..e69de29b 100644 --- a/packages/secubox-metablogizer/api/routers/__init__.py +++ b/packages/secubox-metablogizer/api/routers/__init__.py @@ -1 +0,0 @@ -# secubox-metablogizer routers diff --git a/packages/secubox-metablogizer/api/routers/publish.py b/packages/secubox-metablogizer/api/routers/publish.py new file mode 100644 index 00000000..5e6ad289 --- /dev/null +++ b/packages/secubox-metablogizer/api/routers/publish.py @@ -0,0 +1,84 @@ +# packages/secubox-metablogizer/api/routers/publish.py +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +"""MetaBlogizer publisher wizard: upload -> version -> route -> cert -> backup.""" +from __future__ import annotations + +import tempfile +from pathlib import Path + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from fastapi.responses import FileResponse, JSONResponse +from secubox_core.auth import require_jwt + +from publish.content import extract_archive, ContentError +from publish.routing import apply_route +from publish.certs import provision_cert +from publish.backup import export_site, import_site +from webhook import git_commit_push + +# Mirror the constants owned by api/main.py (kept in sync intentionally). +SITES_ROOT = Path("/srv/metablogizer/sites") +DEFAULT_DOMAIN_SUFFIX = ".gk2.secubox.in" +BASE_PORT = 8900 + +router = APIRouter() + + +def _site_dir(name: str) -> Path: + if not name.replace("-", "").replace("_", "").isalnum(): + raise HTTPException(400, "invalid site name") + d = SITES_ROOT / name + return d + + +@router.post("/publish/wizard") +async def publish_wizard( + name: str = Form(...), + domain: str = Form(None), + file: UploadFile = File(...), + user=Depends(require_jwt), +): + site = _site_dir(name) + docroot = site / "public" + docroot.mkdir(parents=True, exist_ok=True) + domain = domain or f"{name}{DEFAULT_DOMAIN_SUFFIX}" + steps: dict = {} + + data = await file.read() + try: + steps["content"] = extract_archive(docroot, data, file.filename or "index.html") + except ContentError as e: + raise HTTPException(400, f"unsafe upload: {e}") + + steps["version"] = git_commit_push(site, f"publish {name} via wizard") + steps["route"] = apply_route(domain, BASE_PORT) + steps["cert"] = provision_cert(domain) + + ok = bool(steps["content"].get("index_present")) and bool(steps["route"].get("route_ok")) + return {"ok": ok, "domain": domain, "steps": steps} + + +@router.get("/publish/export/{name}") +async def publish_export(name: str, user=Depends(require_jwt)): + site = _site_dir(name) + if not site.exists(): + raise HTTPException(404, "site not found") + manifest = {"name": name, "domain": f"{name}{DEFAULT_DOMAIN_SUFFIX}", + "base_port": BASE_PORT} + out = Path(tempfile.mkdtemp()) + art = export_site(site, manifest, out) + return FileResponse(str(art), filename=art.name, media_type="application/octet-stream") + + +@router.post("/publish/import") +async def publish_import(file: UploadFile = File(...), user=Depends(require_jwt)): + data = await file.read() + tmp = Path(tempfile.mkdtemp()) + art = tmp / "upload.sbxsite" + art.write_bytes(data) + try: + manifest = import_site(art, SITES_ROOT) + except Exception as e: # noqa: BLE001 — surface a clean 400 + raise HTTPException(400, f"import failed: {e}") + return JSONResponse({"ok": True, "manifest": manifest}) diff --git a/packages/secubox-metablogizer/api/tests/test_publish_router.py b/packages/secubox-metablogizer/api/tests/test_publish_router.py new file mode 100644 index 00000000..e31c4cf0 --- /dev/null +++ b/packages/secubox-metablogizer/api/tests/test_publish_router.py @@ -0,0 +1,53 @@ +# packages/secubox-metablogizer/api/tests/test_publish_router.py +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +import io +import zipfile +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +@pytest.fixture +def client(tmp_path, monkeypatch): + monkeypatch.setenv("SECUBOX_JWT_SECRET", "test-secret") + from secubox_core import config as sbx_config + monkeypatch.setattr(sbx_config, "_CONF_PATHS", []) + monkeypatch.setattr(sbx_config, "_CONFIG", None) + import importlib + import routers.publish as rp + importlib.reload(rp) + # Point the site root at tmp and stub privileged deps. + monkeypatch.setattr(rp, "SITES_ROOT", tmp_path / "sites") + (tmp_path / "sites" / "zem" / "public").mkdir(parents=True) + monkeypatch.setattr(rp, "apply_route", lambda domain, port=8900: {"route_ok": True, "vhost": {}, "waf": {}}) + monkeypatch.setattr(rp, "provision_cert", lambda domain: {"mode": "wildcard", "detail": ""}) + monkeypatch.setattr(rp, "git_commit_push", lambda d, m: {"pushed": True, "committed": True, "commit": "abc", "reason": "ok"}) + # Bypass auth + from secubox_core.auth import require_jwt + app = FastAPI() + app.dependency_overrides[require_jwt] = lambda: {"sub": "tester"} + app.include_router(rp.router) + return TestClient(app) + + +def _zip_bytes(): + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as z: + z.writestr("index.html", "

zem

") + return buf.getvalue() + + +def test_wizard_runs_all_steps(client): + r = client.post("/publish/wizard", + data={"name": "zem"}, + files={"file": ("site.zip", _zip_bytes(), "application/zip")}) + assert r.status_code == 200, r.text + body = r.json() + assert body["ok"] is True + assert body["domain"] == "zem.gk2.secubox.in" + assert body["steps"]["content"]["index_present"] is True + assert body["steps"]["route"]["route_ok"] is True + assert body["steps"]["cert"]["mode"] == "wildcard"