db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), _: None = Depends(require_csrf)): """Upload an attachment. Kind inferred from extension; stored with random prefix path. HTML files are stored as editable HTML attachments (variables substituted per recipient).""" content = await file.read() if len(content) > MAX_UPLOAD_BYTES: raise HTTPException(status_code=413, detail="File exceeds 10MB limit") if not content: raise HTTPException(status_code=400, detail="Empty file") orig = file.filename or "attachment.bin" ext = _ext(orig) kind = _kind_for(ext) if kind is None: raise HTTPException(status_code=400, detail=f"Unsupported file type: {ext or 'unknown'}. Allowed: " f"txt, html, pdf, png, jpg, gif, webp, csv, zip") # security: scan buffer for executable magic before persisting verdict, reason = att_svc.scan_for_malware(content, orig) if not verdict: raise HTTPException(status_code=400, detail=f"Rejected by malware scan: {reason}") is_html = kind == "html" html_body = "" if is_html: try: html_body = content.decode("utf-8") except UnicodeDecodeError: raise HTTPException(status_code=400, detail="HTML file is not valid UTF-8") problems = validate_html(html_body) if problems: # validate_html returns a list of problem strings raise HTTPException(status_code=400, detail=f"HTML rejected: {'; '.join(problems)}") # HTML attachments are stored inline (used with variable substitution at send time) att = Attachment( user_id=user.id, name=name or re.sub(r"\.[^.]+$", "", orig) or "html-attachment", filename=orig, stored_path="", mime_type="text/html", size_bytes=len(content), kind="html", description=description or "", is_html_attachment=True, html_attachment_body=html_body, file_sha256=hashlib.sha256(content).hexdigest(), ) else: stored = att_svc.save_upload(orig, content) att = Attachment( user_id=user.id, name=name or re.sub(r"\.[^.]+$", "", orig) or "attachment", filename=orig, stored_path=stored["stored_path"], mime_type=MIME_BY_EXT.get(ext, "application/octet-stream"), size_bytes=len(content), kind=kind, description=description or "", file_sha256=hashlib.sha256(content).hexdigest(), ) db.add(att) await db.commit() await log_audit(db, user.id, "attachment.upload", "attachment", att.id, {"name": att.name, "kind": kind, "sha256": att.file_sha256, "size": len(content)}) return {"id": att.id, **_serialize(att)} @router.post("/convert-pdf") async def convert_html_to_pdf(template_id: int = Query(..., description="HTML template to render"), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), _: None = Depends(require_csrf)): """Convert one of the user's saved HTML templates into a real PDF attachment using WeasyPrint. The generated PDF is stored and can be attached to campaigns.""" tpl = await db.get(EmailTemplate, template_id) if not tpl or tpl.user_id != user.id: raise HTTPException(status_code=404, detail="Template not found") problems = validate_html(tpl.body_html) if problems: # list of problem strings raise HTTPException(status_code=400, detail=f"Template has errors: {'; '.join(problems)}") pdf_bytes = await asyncio.get_event_loop().run_in_executor(None, html_to_pdf, tpl.body_html) if not pdf_bytes: raise HTTPException(status_code=500, detail="PDF rendering failed") verdict, reason = att_svc.scan_for_malware(pdf_bytes, "converted.pdf") if not verdict: raise HTTPException(status_code=500, detail=f"PDF failed safety scan: {reason}") base = re.sub(r"\.[^.]+$", "", tpl.name or "attachment") or "attachment" fname = f"{base}.pdf" stored = att_svc.save_upload(fname, pdf_bytes) att = Attachment( user_id=user.id, name=f"{tpl.name} (PDF)"[:300], filename=fname, stored_path=stored["stored_path"], mime_type="application/pdf", size_bytes=len(pdf_bytes), kind="pdf", description=f"Generated from template '{tpl.name}' via WeasyPrint", html_source_template_id=tpl.id, file_sha256=hashlib.sha256(pdf_bytes).hexdigest(), ) db.add(att) await db.commit() await log_audit(db, user.id, "attachment.convert_pdf", "html_template", tpl.id, {"pdf_attachment_id": att.id, "size": len(pdf_bytes)}) return {"id": att.id, **_serialize(att)} @router.get("/{att_id}") async def get_attachment(att_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): att = await _get_own(db, user, att_id) out = _serialize(att)