teg/backend/chat_link_preview.go
2026-06-13 10:15:57 +02:00

362 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"context"
"errors"
"html"
"io"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"syscall"
"time"
"github.com/jackc/pgx/v5"
)
const (
maxLinkPreviewBytes = 512 * 1024
linkPreviewUserAgent = "Mozilla/5.0 (compatible; TEG-LinkPreview/1.0)"
)
var (
errLinkPreviewBlocked = errors.New("zieladresse ist nicht erlaubt")
chatURLPattern = regexp.MustCompile(`https?://[^\s<>"']+`)
metaTagPattern = regexp.MustCompile(`(?is)<meta\b[^>]*>`)
metaAttrPattern = regexp.MustCompile(`(?is)([a-zA-Z:_-]+)\s*=\s*"([^"]*)"|([a-zA-Z:_-]+)\s*=\s*'([^']*)'`)
titleTagPattern = regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
htmlTagPattern = regexp.MustCompile(`(?is)<[^>]+>`)
trailingTrimSet = ".,!?;:)]}'\""
)
// firstHTTPURL liefert die erste http(s)-URL aus dem Nachrichtentext.
func firstHTTPURL(body string) string {
match := chatURLPattern.FindString(body)
if match == "" {
return ""
}
return strings.TrimRight(match, trailingTrimSet)
}
// isBlockedIP verhindert SSRF: interne/lokale Adressbereiche sind tabu.
func isBlockedIP(ip net.IP) bool {
if ip == nil {
return true
}
if ip4 := ip.To4(); ip4 != nil {
ip = ip4
}
return ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsUnspecified() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() ||
ip.IsMulticast()
}
// safeDialControl wird unmittelbar vor dem Verbindungsaufbau mit der bereits
// aufgelösten Ziel-IP aufgerufen das blockt auch DNS-Rebinding.
func safeDialControl(network, address string, _ syscall.RawConn) error {
switch network {
case "tcp", "tcp4", "tcp6":
default:
return errLinkPreviewBlocked
}
host, _, err := net.SplitHostPort(address)
if err != nil {
return errLinkPreviewBlocked
}
if isBlockedIP(net.ParseIP(host)) {
return errLinkPreviewBlocked
}
return nil
}
func newLinkPreviewClient() *http.Client {
dialer := &net.Dialer{
Timeout: 5 * time.Second,
Control: safeDialControl,
}
transport := &http.Transport{
DialContext: dialer.DialContext,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
DisableKeepAlives: true,
}
return &http.Client{
Timeout: 10 * time.Second,
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errLinkPreviewBlocked
}
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
return errLinkPreviewBlocked
}
return nil
},
}
}
func fetchLinkPreview(ctx context.Context, rawURL string) (*ChatLinkPreview, error) {
parsed, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, errLinkPreviewBlocked
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", linkPreviewUserAgent)
req.Header.Set("Accept", "text/html,application/xhtml+xml")
req.Header.Set("Accept-Language", "de,en;q=0.8")
resp, err := newLinkPreviewClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, errLinkPreviewBlocked
}
contentType := strings.ToLower(resp.Header.Get("Content-Type"))
if contentType != "" && !strings.Contains(contentType, "html") {
return nil, errLinkPreviewBlocked
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxLinkPreviewBytes))
if err != nil {
return nil, err
}
preview := parseOpenGraph(string(body), resp.Request.URL)
preview.URL = rawURL
return preview, nil
}
func parseOpenGraph(htmlBody string, baseURL *url.URL) *ChatLinkPreview {
meta := map[string]string{}
for _, tag := range metaTagPattern.FindAllString(htmlBody, 300) {
var key, content string
hasContent := false
for _, attr := range metaAttrPattern.FindAllStringSubmatch(tag, -1) {
name := strings.ToLower(attr[1])
value := attr[2]
if name == "" {
name = strings.ToLower(attr[3])
value = attr[4]
}
value = html.UnescapeString(value)
switch name {
case "property", "name":
key = strings.ToLower(strings.TrimSpace(value))
case "content":
content = value
hasContent = true
}
}
if key != "" && hasContent {
if _, exists := meta[key]; !exists {
meta[key] = content
}
}
}
pick := func(keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(meta[key]); value != "" {
return value
}
}
return ""
}
preview := &ChatLinkPreview{}
preview.Title = pick("og:title", "twitter:title")
if preview.Title == "" {
if match := titleTagPattern.FindStringSubmatch(htmlBody); len(match) > 1 {
preview.Title = strings.TrimSpace(
html.UnescapeString(htmlTagPattern.ReplaceAllString(match[1], "")),
)
}
}
preview.Description = pick("og:description", "twitter:description", "description")
preview.SiteName = pick("og:site_name")
if preview.SiteName == "" && baseURL != nil {
preview.SiteName = baseURL.Hostname()
}
if image := pick("og:image", "og:image:url", "og:image:secure_url", "twitter:image", "twitter:image:src"); image != "" {
preview.ImageURL = resolveImageURL(image, baseURL)
}
preview.Title = truncateRunes(preview.Title, 200)
preview.Description = truncateRunes(preview.Description, 400)
preview.SiteName = truncateRunes(preview.SiteName, 100)
return preview
}
func resolveImageURL(image string, baseURL *url.URL) string {
parsed, err := url.Parse(strings.TrimSpace(image))
if err != nil {
return ""
}
if baseURL != nil {
parsed = baseURL.ResolveReference(parsed)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return ""
}
return parsed.String()
}
func truncateRunes(value string, max int) string {
value = strings.TrimSpace(value)
if runes := []rune(value); len(runes) > max {
return strings.TrimSpace(string(runes[:max]))
}
return value
}
func (s *Server) getChatLinkPreview(ctx context.Context, messageID string) (*ChatLinkPreview, error) {
var preview ChatLinkPreview
err := s.db.QueryRow(
ctx,
`
SELECT url, title, description, image_url, site_name
FROM message_link_previews
WHERE message_id = $1
`,
messageID,
).Scan(
&preview.URL,
&preview.Title,
&preview.Description,
&preview.ImageURL,
&preview.SiteName,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &preview, nil
}
type chatLinkPreviewRequest struct {
URL string `json:"url"`
}
// handleChatLinkPreview liefert eine Vorschau für eine einzelne URL on demand,
// damit der Absender sie schon vor dem Senden über dem Eingabefeld sieht.
func (s *Server) handleChatLinkPreview(w http.ResponseWriter, r *http.Request) {
if _, ok := userFromContext(r.Context()); !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
var input chatLinkPreviewRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
rawURL := firstHTTPURL(input.URL)
if rawURL == "" {
writeJSON(w, http.StatusOK, map[string]any{"preview": nil})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
defer cancel()
preview, err := fetchLinkPreview(ctx, rawURL)
if err != nil ||
preview == nil ||
(preview.Title == "" && preview.Description == "" && preview.ImageURL == "") {
writeJSON(w, http.StatusOK, map[string]any{"preview": nil})
return
}
writeJSON(w, http.StatusOK, map[string]any{"preview": preview})
}
// scheduleLinkPreview lädt sofern die Nachricht eine URL enthält die
// Vorschau asynchron, damit der Versand nicht durch externe Requests blockiert.
func (s *Server) scheduleLinkPreview(message ChatMessage) {
rawURL := firstHTTPURL(message.Body)
if rawURL == "" {
return
}
go s.generateLinkPreview(message.ID, rawURL)
}
func (s *Server) generateLinkPreview(messageID string, rawURL string) {
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
preview, err := fetchLinkPreview(ctx, rawURL)
if err != nil || preview == nil {
return
}
if preview.Title == "" && preview.Description == "" && preview.ImageURL == "" {
return
}
_, err = s.db.Exec(
ctx,
`
INSERT INTO message_link_previews (
message_id, url, title, description, image_url, site_name
)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (message_id) DO UPDATE SET
url = EXCLUDED.url,
title = EXCLUDED.title,
description = EXCLUDED.description,
image_url = EXCLUDED.image_url,
site_name = EXCLUDED.site_name
`,
messageID,
preview.URL,
preview.Title,
preview.Description,
preview.ImageURL,
preview.SiteName,
)
if err != nil {
return
}
message, err := s.getChatMessage(ctx, messageID)
if err != nil {
return
}
s.publishChatMessageUpdate(ctx, message)
}