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) }