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)]*>`)
metaAttrPattern = regexp.MustCompile(`(?is)([a-zA-Z:_-]+)\s*=\s*"([^"]*)"|([a-zA-Z:_-]+)\s*=\s*'([^']*)'`)
titleTagPattern = regexp.MustCompile(`(?is)
]*>(.*?)`)
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)
}