Skip to content

Commit

Permalink
add pdf report route
Browse files Browse the repository at this point in the history
  • Loading branch information
taylorwalton committed Nov 5, 2024
1 parent 42b2859 commit bd7472a
Show file tree
Hide file tree
Showing 5 changed files with 255 additions and 18 deletions.
30 changes: 15 additions & 15 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Docker

on:
push:
branches: [main]
branches: [convert-docx-to-pdf]

jobs:
build-backend:
Expand All @@ -27,16 +27,16 @@ jobs:
with:
context: ./backend
push: true
tags: ghcr.io/socfortress/copilot-backend:latest
tags: ghcr.io/socfortress/copilot-backend:lab
build-args: |
COPILOT_API_KEY=${{ secrets.COPILOT_API_KEY }}
- name: Notify Discord
uses: appleboy/[email protected]
with:
webhook_id: ${{ secrets.DISCORD_WEBHOOK_ID }}
webhook_token: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
message: "Docker image for backend has been updated."
# - name: Notify Discord
# uses: appleboy/[email protected]
# with:
# webhook_id: ${{ secrets.DISCORD_WEBHOOK_ID }}
# webhook_token: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
# message: "Docker image for backend has been updated."

build-frontend:
runs-on: ubuntu-latest
Expand All @@ -60,11 +60,11 @@ jobs:
with:
context: ./frontend
push: true
tags: ghcr.io/socfortress/copilot-frontend:latest
tags: ghcr.io/socfortress/copilot-frontend:lab

- name: Notify Discord
uses: appleboy/[email protected]
with:
webhook_id: ${{ secrets.DISCORD_WEBHOOK_ID }}
webhook_token: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
message: "Docker image for frontend has been updated."
# - name: Notify Discord
# uses: appleboy/[email protected]
# with:
# webhook_id: ${{ secrets.DISCORD_WEBHOOK_ID }}
# webhook_token: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
# message: "Docker image for frontend has been updated."
11 changes: 8 additions & 3 deletions backend/app/incidents/routes/db_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -933,13 +933,18 @@ async def upload_case_report_template_endpoint(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
):
# Check if the file type is a .docx
# Check if the file type is a .docx or .html
mime_type, _ = mimetypes.guess_type(file.filename)
if mime_type != "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
raise HTTPException(status_code=400, detail="Invalid file type. Only .docx files are allowed.")
allowed_mime_types = [
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", # .docx
"text/html" # .html
]
if mime_type not in allowed_mime_types:
raise HTTPException(status_code=400, detail="Invalid file type. Only .docx and .html files are allowed.")

if await report_template_exists(file.filename, db):
raise HTTPException(status_code=400, detail="File name already exists for this template")

return CaseReportTemplateDataStoreResponse(
case_report_template_data_store=await upload_report_template(file, db),
success=True,
Expand Down
37 changes: 37 additions & 0 deletions backend/app/incidents/routes/incident_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@
from app.incidents.services.reports import download_template
from app.incidents.services.reports import render_document_with_context
from app.incidents.services.reports import save_template_to_tempfile
from app.incidents.services.reports_pdf import convert_html_to_pdf
from app.incidents.services.reports_pdf import create_file_response_pdf
from app.incidents.services.reports_pdf import download_template_pdf
from app.incidents.services.reports_pdf import render_html_template
from app.incidents.services.reports_pdf import create_case_context_pdf


incidents_report_router = APIRouter()

Expand Down Expand Up @@ -202,5 +208,36 @@ async def get_cases_export_docx_route(

return response

@incidents_report_router.post(
"/generate-report-pdf",
description="Generate a PDF report for a case.",
)
async def get_cases_export_pdf_route(
request: CaseDownloadDocxRequest,
session: AsyncSession = Depends(get_db),
) -> FileResponse:
case = await fetch_case_by_id(session, request.case_id)
if not case:
raise HTTPException(status_code=404, detail="No cases found")

context = create_case_context_pdf(case)

# Download and save the template
tmp_template_name = await download_template_pdf(request.template_name)

# Render the HTML template with the context
rendered_html_file_name = render_html_template(tmp_template_name, context)

# Convert HTML to PDF using WeasyPrint
rendered_pdf_file_name = convert_html_to_pdf(rendered_html_file_name)

# Create the FileResponse for PDF
response = create_file_response_pdf(file_path=rendered_pdf_file_name, file_name=request.file_name.replace(".docx", ".pdf"))

# Clean up temporary files
cleanup_temp_files([tmp_template_name, rendered_html_file_name])

return response


# ! TODO: ROUTE FOR MARKDOWN TEMPLATE ! #
145 changes: 145 additions & 0 deletions backend/app/incidents/services/reports_pdf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import os
import pdfkit
from tempfile import NamedTemporaryFile
from typing import Dict, Optional
from jinja2 import Environment, FileSystemLoader
from fastapi.responses import FileResponse
import platform
from app.data_store.data_store_operations import download_data_store

async def download_template_pdf(template_name: str) -> str:
"""Retrieve the template file content from the data store and save it to a temporary file."""
template_content = await download_data_store(bucket_name="copilot-case-report-templates", object_name=template_name)
with NamedTemporaryFile(delete=False, suffix=".html") as tmp_template:
tmp_template.write(template_content)
return tmp_template.name

def create_case_context_pdf(case) -> Dict[str, Dict[str, str]]:
"""Prepare the context for the Jinja template."""
return {
"case": {
"name": case.case_name,
"description": case.case_description,
"assigned_to": case.assigned_to,
"case_creation_time": case.case_creation_time,
"id": case.id,
"alerts": [
{
"alert_name": alert.alert.alert_name,
"alert_description": alert.alert.alert_description,
"status": alert.alert.status,
"tags": [tag.tag.tag for tag in alert.alert.tags],
"assets": [
{
"asset_name": asset.asset_name,
"agent_id": asset.agent_id,
}
for asset in alert.alert.assets
],
"comments": [
{
"comment": comment.comment,
"user_name": comment.user_name,
"created_at": comment.created_at,
}
for comment in alert.alert.comments
],
"context": {
"source": alert.alert.assets[0].alert_context.source
if alert.alert.assets and alert.alert.assets[0].alert_context
else None,
"context": alert.alert.assets[0].alert_context.context
if alert.alert.assets and alert.alert.assets[0].alert_context
else None,
}
if alert.alert.assets
else None,
"iocs": [
{
"ioc_value": ioc.ioc.value,
"ioc_type": ioc.ioc.type,
"ioc_description": ioc.ioc.description,
}
for ioc in alert.alert.iocs
],
}
for alert in case.alerts
],
},
}

def render_html_template(template_path: str, context: Dict[str, Dict[str, str]]) -> str:
"""Render the Jinja HTML template with the provided context."""
template_dir = os.path.dirname(template_path)
template_name = os.path.basename(template_path)

env = Environment(loader=FileSystemLoader(template_dir))
template = env.get_template(template_name)
rendered_html = template.render(context)

# Save rendered HTML to a temporary file
with NamedTemporaryFile(delete=False, suffix=".html") as tmp:
tmp.write(rendered_html.encode("utf-8"))
return tmp.name

def convert_html_to_pdf(html_path: str) -> str:
"""Convert the HTML file to a PDF using wkhtmltopdf via pdfkit, with dynamic path detection for different platforms."""
pdf_path = html_path.replace(".html", ".pdf")
wkhtmltopdf_paths = []

# Determine paths to wkhtmltopdf based on the current platform
try:
if platform.system() == "Windows":
# Common installation paths for wkhtmltopdf on Windows
wkhtmltopdf_paths = [
r"C:\Program Files\wkhtmltopdf\bin\wkhtmltopdf.exe",
r"C:\Program Files (x86)\wkhtmltopdf\bin\wkhtmltopdf.exe"
]
elif platform.system() == "Darwin": # macOS
# Common installation paths for wkhtmltopdf on macOS
wkhtmltopdf_paths = [
"/usr/local/bin/wkhtmltopdf",
"/opt/homebrew/bin/wkhtmltopdf" # For macOS ARM (M1/M2) using Homebrew
]
elif platform.system() == "Linux":
# Common installation paths for wkhtmltopdf on Linux (Debian-based)
wkhtmltopdf_paths = [
"/usr/bin/wkhtmltopdf",
"/usr/local/bin/wkhtmltopdf"
]

# Try each path until a valid executable is found
path_to_wkhtmltopdf = None
for path in wkhtmltopdf_paths:
try:
# Check if the executable can be accessed
config = pdfkit.configuration(wkhtmltopdf=path)
path_to_wkhtmltopdf = path
break
except OSError:
continue

# Raise an exception if no valid wkhtmltopdf path is found
if path_to_wkhtmltopdf is None:
raise FileNotFoundError("No valid wkhtmltopdf executable found. Ensure wkhtmltopdf is installed and accessible.")

# Generate the PDF from HTML using the valid wkhtmltopdf path
pdfkit.from_file(html_path, pdf_path, configuration=config)
except Exception as e:
raise RuntimeError(f"Failed to convert HTML to PDF: {str(e)}")

return pdf_path

def create_file_response_pdf(file_path: str, file_name: Optional[str] = "case_report.pdf") -> FileResponse:
"""Create a FileResponse object for the rendered document."""
return FileResponse(
file_path,
filename=file_name,
media_type="application/pdf",
)

def cleanup_temp_files(file_paths: list):
"""Clean up the temporary files."""
for file_path in file_paths:
if os.path.exists(file_path):
os.remove(file_path)
50 changes: 50 additions & 0 deletions backend/app/incidents/templates/case_report_jinja_template.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html>
<head>
<title>Case Report</title>
<style>
body { font-family: Arial, sans-serif; }
.case-info { margin-bottom: 20px; }
.alert { margin-bottom: 15px; }
</style>
</head>
<body>
<h1>Case Report</h1>
<div class="case-info">
<p><strong>Name of Case:</strong> {{ case.name }}</p>
<p><strong>Description:</strong> {{ case.description }}</p>
<p><strong>Assigned To:</strong> {{ case.assigned_to }}</p>
<p><strong>Case Creation Time:</strong> {{ case.case_creation_time }}</p>
<p><strong>Case ID:</strong> {{ case.id }}</p>
</div>

<h2>Alerts:</h2>
{% for alert in case.alerts %}
<div class="alert">
<p><strong>Alert Name:</strong> {{ alert.alert_name }}</p>
<p><strong>Description:</strong> {{ alert.alert_description }}</p>
<p><strong>Status:</strong> {{ alert.status }}</p>
<p><strong>Tags:</strong> {{ alert.tags | join(', ') }}</p>

<h3>Assets:</h3>
{% for asset in alert.assets %}
<p>- <strong>Asset Name:</strong> {{ asset.asset_name }} | <strong>Agent ID:</strong> {{ asset.agent_id }}</p>
{% endfor %}

<h3>Comments:</h3>
{% for comment in alert.comments %}
<p>- "{{ comment.comment }}" by {{ comment.user_name }} at {{ comment.created_at }}</p>
{% endfor %}

<h3>Context:</h3>
<p><strong>Source:</strong> {{ alert.context.source }}</p>
<p><strong>Context Details:</strong> {{ alert.context.context }}</p>

<h3>IoCs:</h3>
{% for ioc in alert.iocs %}
<p>- <strong>IoC Value:</strong> {{ ioc.ioc_value }} | <strong>Type:</strong> {{ ioc.ioc_type }} | <strong>Description:</strong> {{ ioc.ioc_description }}</p>
{% endfor %}
</div>
{% endfor %}
</body>
</html>

0 comments on commit bd7472a

Please sign in to comment.