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:
2026-08-17 17:50:55 -06:00
co-authored by Claude Sonnet 5
parent 752da74c5a
commit 760d2cf5dd
18 changed files with 835 additions and 21 deletions
+21
View File
@@ -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(