refactor: decompose monolith — app.py 10,600→143 lines, 30+ modules
Fase 0 complete: extract all business logic, auth, database, and 176 endpoints from monolithic app.py into dedicated modules. Structure: - app.py: FastAPI hub (CORS, routers, startup/shutdown) - models.py: 13 Pydantic request models - utils.py: shared utilities (embed status, upload validation, process registries) - config.py: constants, env vars, model catalogs - auth/: crypto, jwt_auth, oidc, rate_limit - database/: db(), init_db(), schema DDL - services/: genai, compliance, chat, terraform, embeddings, cis_reports, mcp - routes/: 13 APIRouter modules (auth, users, oci_config, oci_explorer, genai, mcp, adb, embeddings, reports, chat, terraform, settings, cis_engine) Also: README updated with OCIR auth instructions for manual docker run. 86 tests passing.
This commit is contained in:
569
backend/routes/reports.py
Normal file
569
backend/routes/reports.py
Normal file
@@ -0,0 +1,569 @@
|
||||
"""Report routes — CIS report CRUD, compliance report, OCI health/services."""
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import (
|
||||
APIRouter, HTTPException, Depends, Query, BackgroundTasks, Request,
|
||||
)
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
from config import REPORTS, VERSION, _chat_executor, log
|
||||
from database import db
|
||||
from auth.jwt_auth import (
|
||||
current_user, _user_from_token, require, _audit,
|
||||
_verify_config_access, _verify_report_access,
|
||||
)
|
||||
from models import RunReportReq
|
||||
from utils import running_reports
|
||||
from services.genai import _get_adb_connection
|
||||
from services.cis_reports import _exec_report
|
||||
from services.compliance_gen import (
|
||||
_extract_section,
|
||||
_extract_remediation_section,
|
||||
_extract_audit_section,
|
||||
_extract_rationale_section,
|
||||
_extract_description_section,
|
||||
_format_remediation_html,
|
||||
_search_cis_remediation,
|
||||
_build_findings_table,
|
||||
_check_oci_services,
|
||||
_OCI_SERVICE_DESCRIPTIONS,
|
||||
_build_compliance_html,
|
||||
_generate_compliance_report,
|
||||
)
|
||||
from services.compliance_docx import (
|
||||
_generate_camo_strip,
|
||||
_add_floating_image_to_header,
|
||||
_generate_compliance_docx,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Helper ────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Report CRUD ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/reports/run")
|
||||
async def run_report(req: RunReportReq, bg: BackgroundTasks, u=Depends(require("admin","user"))):
|
||||
if req.level not in (1, 2): raise HTTPException(400, "Level must be 1 or 2")
|
||||
_verify_config_access("oci", req.config_id, u)
|
||||
with db() as c:
|
||||
cfg = c.execute("SELECT * FROM oci_configs WHERE id=?",(req.config_id,)).fetchone()
|
||||
if not cfg: raise HTTPException(404, "Config não encontrada")
|
||||
rid = str(uuid.uuid4())
|
||||
c.execute("INSERT INTO reports (id,user_id,tenancy_name,config_id,level,obp_checks,raw_data,redact_output,status) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
(rid, u["id"], cfg["tenancy_name"], req.config_id, req.level, int(req.obp), int(req.raw), int(req.redact_output), "running"))
|
||||
bg.add_task(_exec_report, rid, dict(cfg), req.regions, req.level, req.obp, req.raw, req.redact_output)
|
||||
_audit(u["id"], u["username"], "run_report", rid)
|
||||
return {"report_id": rid, "status": "running"}
|
||||
|
||||
|
||||
@router.get("/api/reports")
|
||||
async def list_reports(skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), u=Depends(current_user)):
|
||||
with db() as c:
|
||||
q = "SELECT id,user_id,tenancy_name,config_id,status,progress,level,obp_checks,raw_data,redact_output,created_at,completed_at,error_msg FROM reports"
|
||||
rows = c.execute(q+" WHERE user_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?",(u["id"], limit, skip)).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/api/reports/{rid}/progress")
|
||||
async def get_report_progress(rid: str, u=Depends(current_user)):
|
||||
_verify_report_access(rid, u)
|
||||
with db() as c:
|
||||
r = c.execute("SELECT status,progress,created_at,completed_at,error_msg FROM reports WHERE id=?", (rid,)).fetchone()
|
||||
if not r: raise HTTPException(404)
|
||||
return dict(r)
|
||||
|
||||
|
||||
@router.post("/api/reports/{rid}/cancel")
|
||||
async def cancel_report(rid: str, u=Depends(require("admin", "user"))):
|
||||
_verify_report_access(rid, u)
|
||||
with db() as c:
|
||||
r = c.execute("SELECT status, worker_pid FROM reports WHERE id=?", (rid,)).fetchone()
|
||||
if not r: raise HTTPException(404)
|
||||
if r["status"] != "running":
|
||||
raise HTTPException(400, "Report is not running")
|
||||
proc = running_reports.get(rid)
|
||||
if proc:
|
||||
try:
|
||||
proc.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=5)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
running_reports.pop(rid, None)
|
||||
elif r["worker_pid"]:
|
||||
import signal
|
||||
try:
|
||||
os.kill(r["worker_pid"], signal.SIGTERM)
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
with db() as c:
|
||||
c.execute("UPDATE reports SET status='cancelled',error_msg='Cancelled by user',completed_at=datetime('now') WHERE id=?", (rid,))
|
||||
_audit(u["id"], u["username"], "cancel_report", rid)
|
||||
return {"ok": True, "status": "cancelled"}
|
||||
|
||||
|
||||
@router.get("/api/reports/{rid}")
|
||||
async def get_report(rid, u=Depends(current_user)):
|
||||
with db() as c: r=c.execute("SELECT * FROM reports WHERE id=?",(rid,)).fetchone()
|
||||
if not r: raise HTTPException(404)
|
||||
if u["role"]!="admin" and r["user_id"]!=u["id"]: raise HTTPException(403)
|
||||
return dict(r)
|
||||
|
||||
|
||||
@router.delete("/api/reports/{rid}")
|
||||
async def delete_report(rid: str, u=Depends(current_user)):
|
||||
with db() as c:
|
||||
r = c.execute("SELECT id, user_id, status, json_path, html_path FROM reports WHERE id=?", (rid,)).fetchone()
|
||||
if not r: raise HTTPException(404)
|
||||
if u["role"] != "admin" and r["user_id"] != u["id"]: raise HTTPException(403)
|
||||
if r["status"] == "running": raise HTTPException(400, "Cannot delete a running report")
|
||||
# Remove associated files
|
||||
for path_col in ("json_path", "html_path"):
|
||||
p = r[path_col]
|
||||
if p and Path(p).exists():
|
||||
try: Path(p).unlink()
|
||||
except OSError: pass
|
||||
c.execute("DELETE FROM reports WHERE id=?", (rid,))
|
||||
_audit(u["id"], u["username"], "delete_report", rid)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/api/reports/{rid}/summary")
|
||||
async def report_summary(rid: str, u=Depends(current_user)):
|
||||
with db() as c:
|
||||
r = c.execute("SELECT user_id,json_path,tenancy_name,level,created_at FROM reports WHERE id=? AND status='completed'", (rid,)).fetchone()
|
||||
if not r: raise HTTPException(404)
|
||||
if u["role"] != "admin" and r["user_id"] != u["id"]: raise HTTPException(403)
|
||||
jp = r["json_path"]
|
||||
if not jp or not Path(jp).exists():
|
||||
raise HTTPException(400, "Report JSON not found")
|
||||
data = json.loads(Path(jp).read_text())
|
||||
total = passed = failed = 0
|
||||
sections = {}
|
||||
total_findings = 0
|
||||
total_compliant = 0
|
||||
# Format: list of checks with Compliant=Yes/No, Section, Findings, Compliant Items, Total
|
||||
checks = data if isinstance(data, list) else data.get("findings", [])
|
||||
if isinstance(checks, dict):
|
||||
checks = list(checks.values())
|
||||
for check in checks:
|
||||
total += 1
|
||||
compliant = str(check.get("Compliant", check.get("status", ""))).strip().lower()
|
||||
is_pass = compliant in ("yes", "pass")
|
||||
if is_pass:
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
fi = str(check.get("Findings", "0")).strip()
|
||||
ci = str(check.get("Compliant Items", "0")).strip()
|
||||
total_findings += int(fi) if fi.isdigit() else 0
|
||||
total_compliant += int(ci) if ci.isdigit() else 0
|
||||
sec = check.get("Section", check.get("section", "Other"))
|
||||
s = sections.setdefault(sec, {"passed": 0, "failed": 0, "total": 0})
|
||||
s["total"] += 1
|
||||
if is_pass:
|
||||
s["passed"] += 1
|
||||
else:
|
||||
s["failed"] += 1
|
||||
score = round((passed / total * 100), 1) if total else 0
|
||||
return {
|
||||
"tenancy": data.get("tenancy", r["tenancy_name"]) if isinstance(data, dict) else r["tenancy_name"],
|
||||
"level": r["level"] or 2,
|
||||
"regions": data.get("regions", []) if isinstance(data, dict) else [],
|
||||
"generated_at": r["created_at"],
|
||||
"total": total, "passed": passed, "failed": failed,
|
||||
"total_findings": total_findings, "total_compliant": total_compliant,
|
||||
"score": score,
|
||||
"sections": sections
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/reports/{rid}/files")
|
||||
async def list_report_files(rid: str, u=Depends(current_user)):
|
||||
with db() as c:
|
||||
r = c.execute("SELECT user_id FROM reports WHERE id=?", (rid,)).fetchone()
|
||||
if not r: raise HTTPException(404)
|
||||
if u["role"] != "admin" and r["user_id"] != u["id"]: raise HTTPException(403)
|
||||
files = c.execute(
|
||||
"SELECT id,file_name,file_type,file_category,file_size FROM report_files WHERE report_id=? ORDER BY file_category,file_name",
|
||||
(rid,)
|
||||
).fetchall()
|
||||
return [dict(f) for f in files]
|
||||
|
||||
|
||||
@router.get("/api/reports/{rid}/files/{fid}/download")
|
||||
async def download_report_file(rid: str, fid: str, token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
|
||||
u = _user_from_token(token) if token else (await current_user(cred) if cred else None)
|
||||
if not u: raise HTTPException(401, "Not authenticated")
|
||||
with db() as c:
|
||||
r = c.execute("SELECT user_id FROM reports WHERE id=?", (rid,)).fetchone()
|
||||
if not r: raise HTTPException(404)
|
||||
if u["role"] != "admin" and r["user_id"] != u["id"]: raise HTTPException(403)
|
||||
f = c.execute("SELECT * FROM report_files WHERE id=? AND report_id=?", (fid, rid)).fetchone()
|
||||
if not f: raise HTTPException(404)
|
||||
p = Path(f["file_path"])
|
||||
if not p.exists(): raise HTTPException(404, "File not found on disk")
|
||||
return FileResponse(p, filename=f["file_name"])
|
||||
|
||||
|
||||
@router.api_route("/api/reports/{rid}/html", methods=["GET", "HEAD"])
|
||||
async def report_html(rid, token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
|
||||
u = _user_from_token(token) if token else (await current_user(cred) if cred else None)
|
||||
if not u: raise HTTPException(401, "Not authenticated")
|
||||
_verify_report_access(rid, u)
|
||||
with db() as c: r=c.execute("SELECT html_path FROM reports WHERE id=?",(rid,)).fetchone()
|
||||
if not r or not r["html_path"] or not Path(r["html_path"]).exists(): raise HTTPException(404)
|
||||
return FileResponse(r["html_path"], media_type="text/html")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# OCI Health & Services
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
@router.get("/api/oci-health")
|
||||
async def oci_health(u=Depends(current_user)):
|
||||
"""Proxy Oracle Cloud Infrastructure public status API."""
|
||||
import httpx
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.get("https://ocistatus.oraclecloud.com/api/v2/components.json")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
log.warning(f"OCI Health API failed: {e}")
|
||||
raise HTTPException(502, f"Failed to fetch OCI status: {e}")
|
||||
|
||||
|
||||
_OCI_SERVICE_NAMES = ["Vulnerability Scanning Service", "Data Safe", "Cloud Guard", "Bastion", "Security Zones", "Vault"]
|
||||
|
||||
|
||||
@router.get("/api/oci-services/{config_id}")
|
||||
async def get_oci_services_status(config_id: str, detect: int = Query(1), u=Depends(current_user)):
|
||||
"""Get OCI services status (auto-detected + user overrides). Use detect=0 to skip auto-detect."""
|
||||
_verify_config_access("oci", config_id, u)
|
||||
import json as _json
|
||||
# Load user overrides
|
||||
with db() as c:
|
||||
row = c.execute("SELECT value FROM app_settings WHERE key=?", (f"oci_services_{config_id}",)).fetchone()
|
||||
overrides = _json.loads(row["value"]) if row else {}
|
||||
# Auto-detect (skip if detect=0)
|
||||
auto = {}
|
||||
if detect:
|
||||
try:
|
||||
detected = _check_oci_services(config_id)
|
||||
for svc in detected:
|
||||
auto[svc["service"]] = {"status": svc["status"], "description": svc["description"]}
|
||||
except Exception as e:
|
||||
log.warning(f"OCI services auto-detect failed: {e}")
|
||||
# Merge: override wins
|
||||
result = []
|
||||
for name in _OCI_SERVICE_NAMES:
|
||||
ov = overrides.get(name, {})
|
||||
au = auto.get(name, {"status": "WARNING", "description": "Unable to verify"})
|
||||
result.append({
|
||||
"service": name,
|
||||
"auto_status": au["status"],
|
||||
"auto_description": au["description"],
|
||||
"status": ov.get("status", au["status"]),
|
||||
"description": ov.get("description", au["description"]),
|
||||
"overridden": bool(ov),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
@router.put("/api/oci-services/{config_id}")
|
||||
async def set_oci_services_status(config_id: str, request: Request, u=Depends(current_user)):
|
||||
"""Save user overrides for OCI services status."""
|
||||
_verify_config_access("oci", config_id, u)
|
||||
import json as _json
|
||||
body = await request.json()
|
||||
overrides = {}
|
||||
for svc in body:
|
||||
name = svc.get("service", "")
|
||||
if name in _OCI_SERVICE_NAMES and svc.get("overridden"):
|
||||
overrides[name] = {"status": svc.get("status", "OK"), "description": svc.get("description", "")}
|
||||
with db() as c:
|
||||
c.execute("INSERT INTO app_settings (key,value,updated_at) VALUES (?,?,datetime('now')) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
|
||||
(f"oci_services_{config_id}", _json.dumps(overrides)))
|
||||
_audit(u["id"], u["username"], "set_oci_services", config_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# LAD A-Team CIS Compliance Report
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
@router.api_route("/api/reports/{rid}/compliance-report", methods=["GET", "HEAD"])
|
||||
async def compliance_report(rid: str, regen: int = Query(0), token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
|
||||
"""Serve cached LAD A-Team CIS Compliance Report, or return 404 if not generated yet."""
|
||||
u = _user_from_token(token) if token else (await current_user(cred) if cred else None)
|
||||
if not u: raise HTTPException(401, "Not authenticated")
|
||||
_verify_report_access(rid, u)
|
||||
rdir = REPORTS / rid
|
||||
cache_path = rdir / "compliance_report.html"
|
||||
|
||||
if regen == 1 and cache_path.exists():
|
||||
cache_path.unlink()
|
||||
|
||||
if cache_path.exists():
|
||||
return FileResponse(cache_path, media_type="text/html",
|
||||
headers={"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache", "Expires": "0"})
|
||||
|
||||
# Return a friendly HTML page instead of JSON 404 (shown in iframe)
|
||||
marker = rdir / ".compliance_generating"
|
||||
if marker.exists():
|
||||
msg = "Generating compliance report... Please wait."
|
||||
else:
|
||||
msg = "Compliance report not generated yet."
|
||||
return HTMLResponse(
|
||||
f'<html><body style="display:flex;align-items:center;justify-content:center;height:100vh;margin:0;font-family:Calibri,sans-serif;color:#666">'
|
||||
f'<div style="text-align:center"><p style="font-size:14px">{msg}</p></div></body></html>',
|
||||
status_code=200,
|
||||
headers={"Cache-Control": "no-cache"})
|
||||
|
||||
|
||||
@router.post("/api/reports/{rid}/compliance-report/generate")
|
||||
async def generate_compliance_report(rid: str, bg: BackgroundTasks, u=Depends(current_user)):
|
||||
"""Start generating the compliance report in background."""
|
||||
_verify_report_access(rid, u)
|
||||
with db() as c:
|
||||
report = c.execute("SELECT * FROM reports WHERE id=?", (rid,)).fetchone()
|
||||
if not report: raise HTTPException(404, "Report not found")
|
||||
|
||||
rdir = REPORTS / rid
|
||||
csv_path = rdir / "cis_summary_report.csv"
|
||||
if not csv_path.exists():
|
||||
raise HTTPException(404, "cis_summary_report.csv not found")
|
||||
|
||||
# Reject if already generating
|
||||
marker = rdir / ".compliance_generating"
|
||||
if marker.exists():
|
||||
age = time.time() - marker.stat().st_mtime
|
||||
if age < 600:
|
||||
return {"status": "already_generating", "has_rag": False}
|
||||
|
||||
# Mark as generating BEFORE deleting cache
|
||||
marker.write_text(str(uuid.uuid4()), encoding="utf-8")
|
||||
|
||||
# Delete existing cache
|
||||
cache_path = rdir / "compliance_report.html"
|
||||
if cache_path.exists():
|
||||
cache_path.unlink()
|
||||
|
||||
has_adb = False
|
||||
with db() as c:
|
||||
adb_cfgs = c.execute("SELECT * FROM adb_vector_configs WHERE user_id=? AND is_active=1", (u["id"],)).fetchall()
|
||||
if adb_cfgs:
|
||||
has_adb = True
|
||||
|
||||
# Pre-check ADB connection before starting (fail fast instead of timeout loop)
|
||||
if has_adb:
|
||||
adb_cfg = dict(adb_cfgs[0])
|
||||
try:
|
||||
conn = _get_adb_connection(adb_cfg)
|
||||
conn.ping()
|
||||
conn.close()
|
||||
log.info(f"ADB connection OK for compliance report {rid}")
|
||||
except Exception as e:
|
||||
marker.unlink(missing_ok=True)
|
||||
log.warning(f"ADB connection failed for compliance report {rid}: {e}")
|
||||
raise HTTPException(503, f"Conexao com ADB falhou. Verifique a whitelist e a configuracao do ADB. Erro: {e}")
|
||||
|
||||
user_id = u["id"]
|
||||
log.info(f"Compliance report generation started for {rid} (has_adb={has_adb})")
|
||||
|
||||
def _bg_generate():
|
||||
try:
|
||||
log.info(f"Compliance report thread running for {rid}")
|
||||
_generate_compliance_report(rid, user_id, force_rag=has_adb)
|
||||
log.info(f"Compliance report generation completed for {rid}")
|
||||
except Exception as e:
|
||||
log.error(f"Compliance report generation failed for {rid}: {e}", exc_info=True)
|
||||
finally:
|
||||
# Remove generating marker
|
||||
m = REPORTS / rid / ".compliance_generating"
|
||||
if m.exists():
|
||||
m.unlink(missing_ok=True)
|
||||
|
||||
_chat_executor.submit(_bg_generate)
|
||||
return {"status": "generating", "has_rag": has_adb}
|
||||
|
||||
|
||||
@router.get("/api/reports/{rid}/compliance-report/status")
|
||||
async def compliance_report_status(rid: str, u=Depends(current_user)):
|
||||
"""Check compliance report status: ready, generating, or not started."""
|
||||
_verify_report_access(rid, u)
|
||||
rdir = REPORTS / rid
|
||||
cache_path = rdir / "compliance_report.html"
|
||||
marker = rdir / ".compliance_generating"
|
||||
if cache_path.exists():
|
||||
# Clean stale marker if report is ready
|
||||
if marker.exists():
|
||||
marker.unlink(missing_ok=True)
|
||||
return {"ready": True, "generating": False}
|
||||
if marker.exists():
|
||||
age = time.time() - marker.stat().st_mtime
|
||||
if age > 600:
|
||||
marker.unlink(missing_ok=True)
|
||||
log.warning(f"Expired stale compliance marker for {rid} (age={age:.0f}s)")
|
||||
return {"ready": False, "generating": False}
|
||||
# Read progress from marker file
|
||||
try:
|
||||
import json as _json
|
||||
progress = _json.loads(marker.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
progress = {}
|
||||
return {"ready": False, "generating": True,
|
||||
"current": progress.get("current", 0),
|
||||
"total": progress.get("total", 0),
|
||||
"step": progress.get("step", ""),
|
||||
"rec": progress.get("rec", "")}
|
||||
return {"ready": False, "generating": False}
|
||||
|
||||
|
||||
@router.get("/api/reports/{rid}/compliance-report/docx")
|
||||
async def compliance_report_docx(rid: str, token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
|
||||
"""Download DOCX compliance report directly."""
|
||||
import re as _re
|
||||
u = _user_from_token(token) if token else (await current_user(cred) if cred else None)
|
||||
if not u: raise HTTPException(401, "Not authenticated")
|
||||
_verify_report_access(rid, u)
|
||||
with db() as c:
|
||||
report = c.execute("SELECT tenancy_name FROM reports WHERE id=?", (rid,)).fetchone()
|
||||
if not report: raise HTTPException(404)
|
||||
files = c.execute("SELECT file_name, file_path FROM report_files WHERE report_id=?", (rid,)).fetchall()
|
||||
tenancy = report["tenancy_name"] or "report"
|
||||
safe_name = _re.sub(r'[^\w\-]', '_', tenancy)
|
||||
try:
|
||||
file_map = {f["file_name"]: f["file_path"] for f in files}
|
||||
docx_bytes = _generate_compliance_docx(rid, tenancy, file_map)
|
||||
if not docx_bytes:
|
||||
raise HTTPException(500, "DOCX generation failed")
|
||||
log.info(f"DOCX direct download: {len(docx_bytes)} bytes")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.error(f"DOCX generation failed: {e}", exc_info=True)
|
||||
raise HTTPException(500, f"DOCX generation failed: {e}")
|
||||
return Response(
|
||||
content=docx_bytes,
|
||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
headers={"Content-Disposition": f'attachment; filename="CIS_Compliance_Report_{safe_name}.docx"'}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/reports/{rid}/compliance-report/download")
|
||||
async def compliance_report_download(rid: str, token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
|
||||
"""Download ZIP with compliance report PDF (Chromium headless) + CSV files."""
|
||||
import re as _re
|
||||
u = _user_from_token(token) if token else (await current_user(cred) if cred else None)
|
||||
if not u: raise HTTPException(401, "Not authenticated")
|
||||
_verify_report_access(rid, u)
|
||||
|
||||
rdir = REPORTS / rid
|
||||
html_path = rdir / "compliance_report.html"
|
||||
if not html_path.exists():
|
||||
raise HTTPException(404, "Compliance report not generated yet")
|
||||
|
||||
with db() as c:
|
||||
report = c.execute("SELECT tenancy_name FROM reports WHERE id=?", (rid,)).fetchone()
|
||||
if not report: raise HTTPException(404)
|
||||
files = c.execute("SELECT file_name, file_path FROM report_files WHERE report_id=?", (rid,)).fetchall()
|
||||
|
||||
tenancy = report["tenancy_name"] or "report"
|
||||
safe_name = _re.sub(r'[^\w\-]', '_', tenancy)
|
||||
file_map = {f["file_name"]: f["file_path"] for f in files}
|
||||
|
||||
# Prepare HTML for PDF: rewrite CSV links to relative paths
|
||||
html = html_path.read_text(encoding="utf-8")
|
||||
html = _re.sub(r'<button class="print-btn[^"]*"[^>]*>.*?</button>', '', html, flags=_re.DOTALL)
|
||||
html = _re.sub(r'<script>.*?</script>', '', html, flags=_re.DOTALL)
|
||||
for fname in file_map:
|
||||
html = _re.sub(
|
||||
r'href="[^"]*?/download[^"]*?"([^>]*>)\s*\[CSV\]\s*' + _re.escape(fname),
|
||||
f'href="csv/{fname}"\\1[CSV] {fname}',
|
||||
html
|
||||
)
|
||||
|
||||
# Generate PDF via Chromium headless (same engine as browser print)
|
||||
pdf_bytes = None
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmp_html = Path(tmpdir) / "report.html"
|
||||
tmp_pdf = Path(tmpdir) / "report.pdf"
|
||||
tmp_html.write_text(html, encoding="utf-8")
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await asyncio.wait_for(
|
||||
loop.run_in_executor(None, partial(
|
||||
subprocess.run, [
|
||||
"chromium", "--headless", "--no-sandbox", "--disable-gpu",
|
||||
"--disable-software-rasterizer", "--run-all-compositor-stages-before-draw",
|
||||
f"--print-to-pdf={tmp_pdf}", "--no-pdf-header-footer",
|
||||
f"file://{tmp_html}"
|
||||
], capture_output=True, timeout=120)),
|
||||
timeout=120)
|
||||
if tmp_pdf.exists():
|
||||
pdf_bytes = tmp_pdf.read_bytes()
|
||||
log.info(f"Chromium PDF generated: {len(pdf_bytes)} bytes")
|
||||
else:
|
||||
log.error(f"Chromium PDF failed: {result.stderr.decode()[:500]}")
|
||||
except Exception as e:
|
||||
log.error(f"PDF generation failed: {e}", exc_info=True)
|
||||
|
||||
if not pdf_bytes:
|
||||
raise HTTPException(500, "PDF generation failed")
|
||||
|
||||
# Generate DOCX
|
||||
docx_bytes = None
|
||||
try:
|
||||
docx_bytes = _generate_compliance_docx(rid, tenancy, file_map)
|
||||
if docx_bytes:
|
||||
log.info(f"DOCX generated: {len(docx_bytes)} bytes")
|
||||
except Exception as e:
|
||||
log.warning(f"DOCX generation failed (non-fatal): {e}")
|
||||
|
||||
# Build ZIP: PDF + DOCX + CSVs
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr(f"CIS_Compliance_Report_{safe_name}.pdf", pdf_bytes)
|
||||
if docx_bytes:
|
||||
zf.writestr(f"CIS_Compliance_Report_{safe_name}.docx", docx_bytes)
|
||||
for fname, fpath in file_map.items():
|
||||
p = Path(fpath)
|
||||
if p.exists() and fname.lower().endswith('.csv'):
|
||||
zf.write(str(p), f"csv/{fname}")
|
||||
|
||||
buf.seek(0)
|
||||
return Response(
|
||||
content=buf.getvalue(),
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="CIS_Compliance_Report_{safe_name}.zip"'}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/reports/{rid}/download")
|
||||
async def report_dl(rid, fmt: str = Query("json"), token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
|
||||
u = _user_from_token(token) if token else (await current_user(cred) if cred else None)
|
||||
if not u: raise HTTPException(401, "Not authenticated")
|
||||
_verify_report_access(rid, u)
|
||||
with db() as c: r=c.execute("SELECT * FROM reports WHERE id=?",(rid,)).fetchone()
|
||||
if not r: raise HTTPException(404)
|
||||
p = r["json_path"] if fmt=="json" else r["html_path"]
|
||||
if not p or not Path(p).exists(): raise HTTPException(404)
|
||||
return FileResponse(p, filename=f"cis_{r['tenancy_name']}_{rid[:8]}.{fmt}")
|
||||
Reference in New Issue
Block a user