Private
Public Access
Add URL previews for chat messages (#43)
Slack/Discord-style link unfurling: the first http(s) URL in a message's content gets a small preview card (title/description/image/site name) fetched from the page's Open Graph tags. Backend: - Message.preview_url (extracted at create/edit time, cheap regex, no I/O) points at a link_previews cache row keyed by URL -- the same URL posted in different messages/rooms fetches once, and a failed fetch is cached too so a dead URL isn't retried on every reference. - The actual fetch runs in a background asyncio.create_task from broadcast_new_message/broadcast_message_update, on its own DB session, so a slow third-party site never delays message delivery. A separate "link_preview" WS envelope carries the result once it resolves. - SSRF protection reuses app/services/ssrf.py's validate_target_url (renamed from UnsafeWebhookUrlError to UnsafeUrlError now that it's shared with webhooks), but re-validates before every hop of a redirect chain rather than once up front -- redirects are followed manually so each intermediate URL is checked before it's ever connected to. - Parsed with stdlib html.parser -- no new dependency. Frontend: a LinkPreviewCard rendered under message content when present, patched into state live via the new WS envelope and included in message history for reloads. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -5,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Message, MessageFile, MessageMention, Room, RoomMembership, User
|
||||
from app.schemas.message import ReactionSummary
|
||||
from app.services.link_preview_service import fetch_and_broadcast_link_preview
|
||||
from app.services.push_service import send_push_to_user
|
||||
from app.services.webhook_service import dispatch_event
|
||||
from app.ws.broadcaster import Broadcaster
|
||||
@@ -90,12 +92,24 @@ async def _message_payload(db: AsyncSession, message: Message, username: str) ->
|
||||
"content": message.content,
|
||||
"image_id": str(message.image_id) if message.image_id else None,
|
||||
"file": file_payload,
|
||||
# Never populated here -- fetching it is a network call to a
|
||||
# third-party URL, which has no business delaying message delivery.
|
||||
# A separate "link_preview" envelope arrives shortly after (see
|
||||
# _maybe_fetch_link_preview) once/if the fetch succeeds.
|
||||
"link_preview": None,
|
||||
"reactions": [],
|
||||
"created_at": message.created_at.isoformat(),
|
||||
"edited_at": message.edited_at.isoformat() if message.edited_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _maybe_fetch_link_preview(broadcaster: Broadcaster, room_id: uuid.UUID, message: Message) -> None:
|
||||
if message.preview_url:
|
||||
asyncio.create_task(
|
||||
fetch_and_broadcast_link_preview(broadcaster, room_id, message.id, message.preview_url)
|
||||
)
|
||||
|
||||
|
||||
async def broadcast_new_message(
|
||||
db: AsyncSession,
|
||||
broadcaster: Broadcaster,
|
||||
@@ -111,6 +125,7 @@ async def broadcast_new_message(
|
||||
await broadcaster.publish(room_id, payload)
|
||||
await _notify_offline_members(db, broadcaster, presence, room_id, sender, message)
|
||||
await dispatch_event(db, "message.created", room_id, payload)
|
||||
_maybe_fetch_link_preview(broadcaster, room_id, message)
|
||||
|
||||
|
||||
async def broadcast_message_update(
|
||||
@@ -122,9 +137,15 @@ async def broadcast_message_update(
|
||||
"room_id": str(room_id),
|
||||
"content": message.content,
|
||||
"edited_at": message.edited_at.isoformat() if message.edited_at else None,
|
||||
# Lets the frontend clear a stale preview when an edit changes or
|
||||
# removes the URL it came from -- it compares this against the
|
||||
# link_preview it already has for the message rather than blindly
|
||||
# keeping whatever was there before the edit.
|
||||
"preview_url": message.preview_url,
|
||||
}
|
||||
await broadcaster.publish(room_id, payload)
|
||||
await dispatch_event(db, "message.updated", room_id, payload)
|
||||
_maybe_fetch_link_preview(broadcaster, room_id, message)
|
||||
|
||||
|
||||
async def broadcast_reaction_update(
|
||||
|
||||
Reference in New Issue
Block a user