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.
570 lines
25 KiB
Python
570 lines
25 KiB
Python
"""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}")
|