a9b1073877
Сервер сам загружает заголовок, описание и картинку страницы по ссылке из сообщения и отдаёт клиенту готовую карточку. Безопасность (главное здесь): - только http/https и без userinfo; запрет петли, частных сетей, link-local (169.254.169.254), CGNAT, multicast и IPv4-mapped вариантов; - проверка идёт по адресу, к которому реально открывается TCP (`net.Dialer.Control`), поэтому подмена DNS между проверкой и соединением (DNS rebinding) ничего не даёт; - не больше 3 редиректов, каждый хоп проверяется заново; таймаут 5 с, тело ≤ 512 КБ, только `text/html`; прокси из окружения игнорируются, cookie и авторизация не отправляются; в логи попадают только хост и код причины; - картинка по ссылке не скачивается — проверяется лишь её URL: экономия CPU на 1 vCPU и минус класс атак через декодирование. Кэш: таблица `link_previews` (миграция 00022, ключ — sha256 нормализованного URL), TTL по статусу (ok — сутки, empty/blocked — час, error — 10 минут). Ручка `GET /api/v1/link-previews?url=…` отвечает статусом (ok/empty/blocked/error) и карточкой только при ok; 20 новых загрузок в минуту на пользователя, кэшированные ответы лимит не тратят. `UNFURL_ENABLED=false` выключает функцию целиком, `features.unfurl_enabled` виден в `/meta`. Retention убирает истёкшие записи кэша. Тесты: 21 в `internal/unfurl` (включая DNS rebinding через локальный DNS-сервер, редирект во внутреннюю сеть, таймаут, лимиты размера и типа), ручки, store и миграция.
180 lines
5.7 KiB
Go
180 lines
5.7 KiB
Go
package unfurl
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/netip"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"golang.org/x/net/html"
|
|
)
|
|
|
|
// headMetadata — найденные в <head> значения по источникам. Приоритет
|
|
// разбирается в preview: og:* → twitter:* → обычные теги.
|
|
type headMetadata struct {
|
|
ogTitle string
|
|
twitterTitle string
|
|
title string
|
|
ogDescription string
|
|
twitterDescription string
|
|
description string
|
|
siteName string
|
|
ogImage string
|
|
twitterImage string
|
|
}
|
|
|
|
// parseHead разбирает <head> потоково и останавливается на </head>: тело
|
|
// страницы не читается. Читатель обычно ограничен LimitReader, поэтому
|
|
// обрыв по лимиту — не ошибка: возвращается всё, что успели найти.
|
|
func parseHead(reader io.Reader, base *url.URL, allowLoopback bool) (Preview, error) {
|
|
tokenizer := html.NewTokenizer(reader)
|
|
var meta headMetadata
|
|
inTitle := false
|
|
|
|
parse:
|
|
for {
|
|
switch tokenizer.Next() {
|
|
case html.ErrorToken:
|
|
// io.EOF — нормальное завершение (в том числе обрыв по лимиту).
|
|
if err := tokenizer.Err(); err != nil && !errors.Is(err, io.EOF) {
|
|
return meta.preview(base, allowLoopback), fmt.Errorf("чтение html: %w", err)
|
|
}
|
|
break parse
|
|
case html.CommentToken, html.DoctypeToken:
|
|
// Комментарии и doctype не содержат метаданных превью.
|
|
case html.StartTagToken, html.SelfClosingTagToken:
|
|
name, hasAttr := tokenizer.TagName()
|
|
switch string(name) {
|
|
case "title":
|
|
inTitle = true
|
|
case "meta":
|
|
// Атрибуты нужно вычитать до следующего Next.
|
|
meta.add(attributes(tokenizer, hasAttr))
|
|
}
|
|
case html.EndTagToken:
|
|
name, _ := tokenizer.TagName()
|
|
switch string(name) {
|
|
case "title":
|
|
inTitle = false
|
|
case "head":
|
|
break parse
|
|
}
|
|
case html.TextToken:
|
|
if inTitle {
|
|
// Токенизатор уже декодировал HTML-сущности.
|
|
meta.title += string(tokenizer.Text())
|
|
}
|
|
}
|
|
}
|
|
return meta.preview(base, allowLoopback), nil
|
|
}
|
|
|
|
// add раскладывает атрибуты <meta> по источникам. Ключи property/name
|
|
// сравниваются без учёта регистра, первое значение каждого источника
|
|
// выигрывает (повторы в разметке игнорируются).
|
|
func (m *headMetadata) add(attrs map[string]string) {
|
|
key := attrs["property"]
|
|
if key == "" {
|
|
key = attrs["name"]
|
|
}
|
|
content := attrs["content"]
|
|
if key == "" || content == "" {
|
|
return
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(key)) {
|
|
case "og:title":
|
|
setIfEmpty(&m.ogTitle, content)
|
|
case "twitter:title":
|
|
setIfEmpty(&m.twitterTitle, content)
|
|
case "og:description":
|
|
setIfEmpty(&m.ogDescription, content)
|
|
case "twitter:description":
|
|
setIfEmpty(&m.twitterDescription, content)
|
|
case "description":
|
|
setIfEmpty(&m.description, content)
|
|
case "og:site_name":
|
|
setIfEmpty(&m.siteName, content)
|
|
case "og:image":
|
|
setIfEmpty(&m.ogImage, content)
|
|
case "twitter:image":
|
|
setIfEmpty(&m.twitterImage, content)
|
|
}
|
|
}
|
|
|
|
// preview собирает Preview по приоритетам: og:* → twitter:* → обычные теги.
|
|
func (m headMetadata) preview(base *url.URL, allowLoopback bool) Preview {
|
|
image := m.ogImage
|
|
if image == "" {
|
|
image = m.twitterImage
|
|
}
|
|
return Preview{
|
|
Title: sanitize(firstNonEmpty(m.ogTitle, m.twitterTitle, m.title), titleLimit),
|
|
Description: sanitize(firstNonEmpty(m.ogDescription, m.twitterDescription, m.description), descriptionLimit),
|
|
SiteName: sanitize(m.siteName, siteNameLimit),
|
|
ImageURL: absoluteImageURL(image, base, allowLoopback),
|
|
}
|
|
}
|
|
|
|
// absoluteImageURL приводит ссылку на картинку к абсолютному виду относительно
|
|
// финального URL страницы. Картинка не скачивается: проверяется только сам URL
|
|
// (http/https и не внутренний адрес), иначе возвращается пустая строка.
|
|
func absoluteImageURL(raw string, base *url.URL, allowLoopback bool) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" || base == nil {
|
|
return ""
|
|
}
|
|
reference, err := url.Parse(raw)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
normalized, ok := Normalize(base.ResolveReference(reference).String())
|
|
if !ok {
|
|
return ""
|
|
}
|
|
parsed, err := url.Parse(normalized)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
host := parsed.Hostname()
|
|
// DNS для картинки не спрашиваем, но имя localhost отсекаем сразу.
|
|
lowerHost := strings.ToLower(host)
|
|
if lowerHost == "localhost" || strings.HasSuffix(lowerHost, ".localhost") {
|
|
return ""
|
|
}
|
|
if address, err := netip.ParseAddr(host); err == nil && blockedAddr(address, allowLoopback) {
|
|
return ""
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
// attributes вычитывает атрибуты текущего тега в нижнем регистре.
|
|
func attributes(tokenizer *html.Tokenizer, hasAttr bool) map[string]string {
|
|
attrs := make(map[string]string, 4)
|
|
for hasAttr {
|
|
var key, value []byte
|
|
key, value, hasAttr = tokenizer.TagAttr()
|
|
name := strings.ToLower(string(key))
|
|
if _, exists := attrs[name]; !exists {
|
|
attrs[name] = string(value)
|
|
}
|
|
}
|
|
return attrs
|
|
}
|
|
|
|
func setIfEmpty(target *string, value string) {
|
|
if *target == "" {
|
|
*target = value
|
|
}
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|