fix: WeasyPrint URL fetcher + attachment improvements + webhook error propagation + WebSocket conversation check + RLS disabled on system tables (bootstrap fix)
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
This commit is contained in:
@@ -68,7 +68,11 @@ async def _dispatch_single(
|
||||
event_name: str,
|
||||
data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Send a webhook and log the result."""
|
||||
"""Send a webhook and log the result. Raises on failure (P1.5 fix).
|
||||
|
||||
Previously errors were swallowed, causing the outbox to mark events
|
||||
as 'published' even when webhook delivery failed.
|
||||
"""
|
||||
try:
|
||||
result = await send_webhook(webhook, event_name, data)
|
||||
if result["success"]:
|
||||
@@ -81,10 +85,12 @@ async def _dispatch_single(
|
||||
f"Webhook {webhook.id} failed for {webhook.url} "
|
||||
f"event {event_name}: {result.get('error')}"
|
||||
)
|
||||
raise RuntimeError(f"Webhook {webhook.id} failed: {result.get('error')}")
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
f"Webhook {webhook.id} dispatch error for {webhook.url}: {exc}"
|
||||
)
|
||||
raise # Re-raise so outbox can retry (P1.5 fix)
|
||||
|
||||
|
||||
def register_webhook_event_handlers(event_bus: EventBus | None = None) -> None:
|
||||
|
||||
@@ -515,7 +515,23 @@ async def websocket_endpoint(
|
||||
elif msg_type == "subscribe":
|
||||
conv_id = msg.get("conversation_id")
|
||||
if conv_id:
|
||||
ws_manager.subscribe(conv_id, user_id)
|
||||
# P1.9 fix: Check if user is a participant of this conversation
|
||||
from app.core.db import async_session_maker
|
||||
from sqlalchemy import text as sql_text
|
||||
try:
|
||||
async with async_session_maker() as db:
|
||||
await db.execute(sql_text("SELECT set_config('app.current_tenant_id', :tid, true)"), {"tid": tenant_id})
|
||||
result = await db.execute(
|
||||
sql_text("SELECT 1 FROM conversation_participants WHERE conversation_id = :cid AND user_id = :uid"),
|
||||
{"cid": conv_id, "uid": user_id},
|
||||
)
|
||||
if result.first():
|
||||
ws_manager.subscribe(conv_id, user_id)
|
||||
else:
|
||||
await ws_manager.send_to_user(user_id, {"type": "error", "message": "Not a participant of this conversation"})
|
||||
except Exception:
|
||||
logger.warning("Failed to check conversation participation for %s in %s", user_id, conv_id)
|
||||
await ws_manager.send_to_user(user_id, {"type": "error", "message": "Cannot verify participation"})
|
||||
elif msg_type == "unsubscribe":
|
||||
conv_id = msg.get("conversation_id")
|
||||
if conv_id:
|
||||
|
||||
@@ -130,9 +130,23 @@ def render_template_string(template_content: str, data: dict[str, Any]) -> str:
|
||||
return template.render(**data)
|
||||
|
||||
|
||||
def _safe_url_fetcher(url: str, timeout: int = 10) -> dict:
|
||||
"""URL fetcher that only allows data: URIs and blocks external resources.
|
||||
|
||||
Prevents SSRF and local file access via WeasyPrint.
|
||||
"""
|
||||
if url.startswith('data:'):
|
||||
from weasyprint import default_url_fetcher
|
||||
return default_url_fetcher(url, timeout)
|
||||
# Block all external URLs (http, https, file, etc.)
|
||||
raise ValueError(f"External resource blocked by URL fetcher: {url}")
|
||||
|
||||
|
||||
def generate_pdf(html_content: str) -> bytes:
|
||||
"""Generate a PDF from HTML content using WeasyPrint.
|
||||
|
||||
Uses a safe URL fetcher that blocks external resources (SSRF protection).
|
||||
|
||||
Args:
|
||||
html_content: Valid HTML string
|
||||
|
||||
@@ -141,7 +155,7 @@ def generate_pdf(html_content: str) -> bytes:
|
||||
"""
|
||||
from weasyprint import HTML
|
||||
|
||||
pdf = HTML(string=html_content).write_pdf()
|
||||
pdf = HTML(string=html_content, url_fetcher=_safe_url_fetcher).write_pdf()
|
||||
return pdf
|
||||
|
||||
|
||||
|
||||
@@ -94,13 +94,19 @@ async def download_attachment(
|
||||
raise HTTPException(404, detail={"detail": "Attachment not found", "code": "not_found"})
|
||||
|
||||
file_path = data["file_path"]
|
||||
if not os.path.isfile(file_path):
|
||||
raise HTTPException(404, detail={"detail": "File not found on disk", "code": "file_missing"})
|
||||
# Use storage backend instead of os.path.isfile (P1.1 fix)
|
||||
from app.core.storage import get_storage_backend
|
||||
storage = get_storage_backend()
|
||||
try:
|
||||
file_bytes = await storage.load(file_path)
|
||||
except Exception:
|
||||
raise HTTPException(404, detail={"detail": "File not found in storage", "code": "file_missing"})
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=data["filename"],
|
||||
from fastapi.responses import Response
|
||||
return Response(
|
||||
content=file_bytes,
|
||||
media_type=data["mime_type"],
|
||||
headers={"Content-Disposition": f'attachment; filename="{data["filename"]}"'},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -50,6 +50,11 @@ async def save_attachment(
|
||||
is_system_admin: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Save a file to storage and create an Attachment record."""
|
||||
# File size limit: 50MB
|
||||
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
|
||||
if len(file_content) > MAX_FILE_SIZE:
|
||||
raise ValueError(f"File too large: {len(file_content)} bytes (max {MAX_FILE_SIZE})")
|
||||
|
||||
# Check access on parent entity
|
||||
if not is_system_admin:
|
||||
from app.core.visibility import check_single_entity_access
|
||||
@@ -151,7 +156,7 @@ async def delete_attachment(
|
||||
attachment_id: uuid.UUID,
|
||||
is_system_admin: bool = False,
|
||||
) -> bool:
|
||||
"""Soft-delete an attachment (keeps file on disk for audit trail)."""
|
||||
"""Soft-delete an attachment and remove physical file from storage."""
|
||||
q = select(Attachment).where(
|
||||
Attachment.id == attachment_id,
|
||||
Attachment.tenant_id == tenant_id,
|
||||
@@ -169,6 +174,13 @@ async def delete_attachment(
|
||||
if not has_access:
|
||||
raise PermissionError("No access")
|
||||
|
||||
# Delete physical file from storage (P1.1 fix)
|
||||
try:
|
||||
storage = get_storage_backend()
|
||||
await storage.delete(attachment.file_path)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to delete physical file %s: %s", attachment.file_path, exc)
|
||||
|
||||
attachment.deleted_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await log_audit(
|
||||
|
||||
Reference in New Issue
Block a user