362 lines
8.7 KiB
Go
362 lines
8.7 KiB
Go
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)
|
||
}
|