def _ext(name: str) -> str: m = re.search(r"(\.[A-Za-z0-9]+)$", name or "") return m.group(1).lower() if m else "" def _kind_for(ext: str) -> Optional[str]: for kind, exts in ALLOWED_EXTENSIONS.items(): if ext in exts: return kind return None def _serialize(att: Attachment) -> dict: return { "id": att.id, "name": att.name, "kind": att.kind, "filename": att.filename, "mime_type": att.mime_type, "size_bytes": att.size_bytes, "file_sha256": att.file_sha256, "description": att.description, "is_html_attachment": bool(att.is_html_attachment), "created_at": att.created_at.isoformat() if att.created_at else None, } async def _get_own(db: AsyncSession, user: User, att_id: int) -> Attachment: att = await db.get(Attachment, att_id) if not att or att.user_id != user.id: raise HTTPException(status_code=404, detail="Attachment not found") return att @router.get("/list") async def list_attachments(kind: str = Query("", description="filter by kind"), search: str = Query(""), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): q = select(Attachment).where(Attachment.user_id == user.id) if kind: q = q.where(Attachment.kind == kind) if search: q = q.where(Attachment.name.ilike(f"%{search}%")) total = (await db.execute( select(func.count()).select_from(q.subquery()))).scalar() or 0 rows = (await db.execute( q.order_by(Attachment.id.desc()).offset((page - 1) * page_size).limit(page_size) )).scalars().all() return {"attachments": [_serialize(a) for a in rows], "total": total, "page": page, "page_size": page_size} ===UPLOAD=== @router.post("/upload") async def upload_attachment(file: UploadFile = File(...), name: str = Query(""), description: str = Query(""), 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.get("errors"): raise HTTPException(status_code=400, detail=f"HTML rejected: {'; '.join(problems['errors'])}") # 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")