Files

180 lines
5.7 KiB
Go
Raw Permalink Normal View History

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