483 lines
18 KiB
Go
483 lines
18 KiB
Go
|
|
// Package unfurl загружает серверные превью ссылок (unfurl, фаза 7 AGENT.md):
|
||
|
|
// заголовок, описание, имя сайта и картинку из <head> страницы.
|
||
|
|
//
|
||
|
|
// Пакет намеренно недоверчив к внешнему миру:
|
||
|
|
// - только схемы http/https, без userinfo;
|
||
|
|
// - запрет внутренних адресов, причём проверяется именно тот адрес, куда
|
||
|
|
// идёт TCP (net.Dialer.Control) — это защищает от DNS rebinding;
|
||
|
|
// - не больше трёх редиректов, каждый хоп проверяется заново;
|
||
|
|
// - общий таймаут, ограничение размера тела и только text/html;
|
||
|
|
// - картинка по ссылке не скачивается: проверяется лишь её URL.
|
||
|
|
//
|
||
|
|
// Страница без метаданных — не ошибка: Fetch возвращает Preview с пустыми
|
||
|
|
// полями и nil. Ошибка означает, что превью получить нельзя (запрещённый
|
||
|
|
// адрес, не тот тип содержимого, таймаут, слишком большой ответ). Это и есть
|
||
|
|
// способ отличить «страница без превью» (status "empty") от сбоя (status
|
||
|
|
// "blocked"/"error") на стороне HTTP-слоя.
|
||
|
|
package unfurl
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"crypto/sha256"
|
||
|
|
"encoding/hex"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"log/slog"
|
||
|
|
"mime"
|
||
|
|
"net"
|
||
|
|
"net/http"
|
||
|
|
"net/netip"
|
||
|
|
"net/url"
|
||
|
|
"strings"
|
||
|
|
"syscall"
|
||
|
|
"time"
|
||
|
|
"unicode"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Значения по умолчанию для Options.
|
||
|
|
const (
|
||
|
|
defaultTimeout = 5 * time.Second
|
||
|
|
defaultMaxBytes = 512 << 10 // 512 KiB
|
||
|
|
defaultUserAgent = "glchat/1.0 (+link preview)"
|
||
|
|
)
|
||
|
|
|
||
|
|
// maxRedirects — сколько переходов разрешено отследить (AGENT.md: не больше 3).
|
||
|
|
const maxRedirects = 3
|
||
|
|
|
||
|
|
// Ограничения на текст превью (в рунах) и заголовки запроса.
|
||
|
|
const (
|
||
|
|
titleLimit = 200
|
||
|
|
descriptionLimit = 400
|
||
|
|
siteNameLimit = 100
|
||
|
|
acceptHTML = "text/html,application/xhtml+xml"
|
||
|
|
maxResponseHeaderBytes = 64 << 10
|
||
|
|
)
|
||
|
|
|
||
|
|
var (
|
||
|
|
// ErrUnsupported — URL не поддержан: чужая схема, userinfo, пустой хост.
|
||
|
|
ErrUnsupported = errors.New("unsupported url")
|
||
|
|
// ErrBlocked — адрес запрещён: петля, частная сеть, link-local и т.п.
|
||
|
|
ErrBlocked = errors.New("address is not allowed")
|
||
|
|
// ErrTooLarge — ответ заведомо больше MaxBytes (по Content-Length).
|
||
|
|
ErrTooLarge = errors.New("response is too large")
|
||
|
|
// ErrContentType — сервер отдал не HTML.
|
||
|
|
ErrContentType = errors.New("unsupported content type")
|
||
|
|
)
|
||
|
|
|
||
|
|
// errTooManyRedirects — внутренняя ошибка: цепочка переходов длиннее лимита.
|
||
|
|
// Наружу приходит обёрнутой в *url.Error, проверяется через errors.Is.
|
||
|
|
var errTooManyRedirects = errors.New("too many redirects")
|
||
|
|
|
||
|
|
// Preview — то, что показываем клиенту.
|
||
|
|
type Preview struct {
|
||
|
|
URL string // нормализованный URL (как в запросе)
|
||
|
|
Title string
|
||
|
|
Description string
|
||
|
|
SiteName string
|
||
|
|
ImageURL string // абсолютный http(s) URL или ""
|
||
|
|
}
|
||
|
|
|
||
|
|
// Empty сообщает, что метаданных не нашлось: клиенту показывать нечего,
|
||
|
|
// но и ошибкой это не является.
|
||
|
|
func (p Preview) Empty() bool {
|
||
|
|
return p.Title == "" && p.Description == "" && p.SiteName == "" && p.ImageURL == ""
|
||
|
|
}
|
||
|
|
|
||
|
|
// Options — настройки загрузчика превью.
|
||
|
|
type Options struct {
|
||
|
|
Timeout time.Duration // по умолчанию 5s
|
||
|
|
MaxBytes int64 // по умолчанию 512 KiB
|
||
|
|
UserAgent string // по умолчанию "glchat/1.0 (+link preview)"
|
||
|
|
Logger *slog.Logger // может быть nil
|
||
|
|
|
||
|
|
// AllowPrivate ослабляет проверку адресов для тестов: разрешает петлевые
|
||
|
|
// адреса (127.0.0.0/8, ::1), на которых поднимаются httptest-серверы.
|
||
|
|
// Частные сети, link-local (в том числе метаданные облака), multicast и
|
||
|
|
// unspecified остаются запрещёнными и в этом режиме — «если сомневаешься,
|
||
|
|
// не тяни ресурс». В продакшене флаг всегда false.
|
||
|
|
AllowPrivate bool
|
||
|
|
|
||
|
|
// Resolver — подменяемый резолвер DNS (тесты); nil — системный.
|
||
|
|
Resolver *net.Resolver
|
||
|
|
}
|
||
|
|
|
||
|
|
// Fetcher загружает превью ссылок. Безопасен для параллельного использования.
|
||
|
|
type Fetcher struct {
|
||
|
|
client *http.Client
|
||
|
|
timeout time.Duration
|
||
|
|
maxBytes int64
|
||
|
|
userAgent string
|
||
|
|
logger *slog.Logger
|
||
|
|
allowPrivate bool
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewFetcher собирает загрузчик с безопасным HTTP-клиентом: без прокси из
|
||
|
|
// окружения, без cookie и авторизации, с проверкой адреса перед подключением.
|
||
|
|
func NewFetcher(opts Options) *Fetcher {
|
||
|
|
timeout := opts.Timeout
|
||
|
|
if timeout <= 0 {
|
||
|
|
timeout = defaultTimeout
|
||
|
|
}
|
||
|
|
maxBytes := opts.MaxBytes
|
||
|
|
if maxBytes <= 0 {
|
||
|
|
maxBytes = defaultMaxBytes
|
||
|
|
}
|
||
|
|
userAgent := strings.TrimSpace(opts.UserAgent)
|
||
|
|
if userAgent == "" {
|
||
|
|
userAgent = defaultUserAgent
|
||
|
|
}
|
||
|
|
|
||
|
|
fetcher := &Fetcher{
|
||
|
|
timeout: timeout,
|
||
|
|
maxBytes: maxBytes,
|
||
|
|
userAgent: userAgent,
|
||
|
|
logger: opts.Logger,
|
||
|
|
allowPrivate: opts.AllowPrivate,
|
||
|
|
}
|
||
|
|
|
||
|
|
dialer := &net.Dialer{
|
||
|
|
Timeout: timeout,
|
||
|
|
KeepAlive: -1,
|
||
|
|
// Control вызывается после разрешения имени и до TCP-подключения,
|
||
|
|
// поэтому проверяется реально подключаемый адрес.
|
||
|
|
Control: fetcher.control,
|
||
|
|
}
|
||
|
|
if opts.Resolver != nil {
|
||
|
|
dialer.Resolver = opts.Resolver
|
||
|
|
}
|
||
|
|
|
||
|
|
fetcher.client = &http.Client{
|
||
|
|
Transport: &http.Transport{
|
||
|
|
// Прокси из окружения игнорируем: иначе проверка адреса
|
||
|
|
// обходится через HTTP_PROXY, а запрос уходит не туда.
|
||
|
|
Proxy: nil,
|
||
|
|
DialContext: dialer.DialContext,
|
||
|
|
TLSHandshakeTimeout: timeout,
|
||
|
|
ResponseHeaderTimeout: timeout,
|
||
|
|
ExpectContinueTimeout: time.Second,
|
||
|
|
MaxResponseHeaderBytes: maxResponseHeaderBytes,
|
||
|
|
DisableKeepAlives: true,
|
||
|
|
ForceAttemptHTTP2: false,
|
||
|
|
},
|
||
|
|
CheckRedirect: fetcher.checkRedirect,
|
||
|
|
}
|
||
|
|
return fetcher
|
||
|
|
}
|
||
|
|
|
||
|
|
// Fetch загружает страницу и разбирает метаданные её <head>. Тело читается не
|
||
|
|
// целиком: разбор прекращается на </head> или по достижении MaxBytes. Если
|
||
|
|
// тело оборвано лимитом, но часть метаданных уже разобрана, они возвращаются
|
||
|
|
// без ошибки. Ошибка возвращается, только когда превью получить не удалось.
|
||
|
|
func (f *Fetcher) Fetch(ctx context.Context, rawURL string) (Preview, error) {
|
||
|
|
normalized, ok := Normalize(rawURL)
|
||
|
|
if !ok {
|
||
|
|
// Хоста может не быть вовсе (file://, javascript:), поэтому в логе
|
||
|
|
// только код причины.
|
||
|
|
f.logWarn("", reasonUnsupported)
|
||
|
|
return Preview{}, fmt.Errorf("unfurl: %w", ErrUnsupported)
|
||
|
|
}
|
||
|
|
parsed, err := url.Parse(normalized)
|
||
|
|
if err != nil {
|
||
|
|
// Normalize уже разобрал URL, сюда попасть нельзя.
|
||
|
|
return Preview{}, fmt.Errorf("unfurl: %w", ErrUnsupported)
|
||
|
|
}
|
||
|
|
host := parsed.Hostname()
|
||
|
|
|
||
|
|
// IP-литералы отсекаем сразу: до DNS, соединения и редиректов.
|
||
|
|
if address, err := netip.ParseAddr(host); err == nil && blockedAddr(address, f.allowPrivate) {
|
||
|
|
f.logWarn(host, reasonBlocked)
|
||
|
|
return Preview{}, fmt.Errorf("unfurl %s: %w", host, ErrBlocked)
|
||
|
|
}
|
||
|
|
|
||
|
|
ctx, cancel := context.WithTimeout(ctx, f.timeout)
|
||
|
|
defer cancel()
|
||
|
|
|
||
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, normalized, nil)
|
||
|
|
if err != nil {
|
||
|
|
return Preview{}, fmt.Errorf("unfurl %s: create request: %w", host, err)
|
||
|
|
}
|
||
|
|
// Только эти заголовки: ни cookie, ни авторизации, ни Referer.
|
||
|
|
request.Header.Set("User-Agent", f.userAgent)
|
||
|
|
request.Header.Set("Accept", acceptHTML)
|
||
|
|
|
||
|
|
response, err := f.client.Do(request)
|
||
|
|
if err != nil {
|
||
|
|
// Ошибки Control и CheckRedirect уже несут нужные sentinel-ошибки.
|
||
|
|
cause := stripURL(err)
|
||
|
|
f.logWarn(host, reasonOf(cause))
|
||
|
|
return Preview{}, fmt.Errorf("unfurl %s: %w", host, cause)
|
||
|
|
}
|
||
|
|
defer response.Body.Close()
|
||
|
|
|
||
|
|
if response.ContentLength > f.maxBytes {
|
||
|
|
f.logWarn(host, reasonTooLarge)
|
||
|
|
return Preview{}, fmt.Errorf("unfurl %s: %w", host, ErrTooLarge)
|
||
|
|
}
|
||
|
|
if err := checkContentType(response.Header.Get("Content-Type")); err != nil {
|
||
|
|
f.logWarn(host, reasonContentType)
|
||
|
|
return Preview{}, fmt.Errorf("unfurl %s: %w", host, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// База для относительных ссылок — финальный URL после редиректов.
|
||
|
|
base := response.Request.URL
|
||
|
|
if base == nil {
|
||
|
|
base = parsed
|
||
|
|
}
|
||
|
|
preview, err := parseHead(io.LimitReader(response.Body, f.maxBytes), base, f.allowPrivate)
|
||
|
|
preview.URL = normalized
|
||
|
|
if err != nil {
|
||
|
|
if !preview.Empty() {
|
||
|
|
// Часть данных успела разобраться — отдаём её, а сбой только логируем.
|
||
|
|
f.logDebug(host, reasonParseFailed)
|
||
|
|
return preview, nil
|
||
|
|
}
|
||
|
|
f.logWarn(host, reasonParseFailed)
|
||
|
|
return Preview{}, fmt.Errorf("unfurl %s: %w", host, err)
|
||
|
|
}
|
||
|
|
f.logDebug(host, reasonOK)
|
||
|
|
return preview, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Коды причин для логов: полный URL с query в логи не попадает, остаётся
|
||
|
|
// только хост и понятная причина (приватность, AGENT.md 9.7).
|
||
|
|
const (
|
||
|
|
reasonOK = "ok"
|
||
|
|
reasonBlocked = "blocked"
|
||
|
|
reasonUnsupported = "unsupported"
|
||
|
|
reasonTooLarge = "too_large"
|
||
|
|
reasonContentType = "content_type"
|
||
|
|
reasonTimeout = "timeout"
|
||
|
|
reasonCanceled = "canceled"
|
||
|
|
reasonRedirect = "redirect"
|
||
|
|
reasonParseFailed = "parse_failed"
|
||
|
|
reasonRequest = "request_failed"
|
||
|
|
)
|
||
|
|
|
||
|
|
// reasonOf сводит ошибку к короткому коду для лога.
|
||
|
|
func reasonOf(err error) string {
|
||
|
|
switch {
|
||
|
|
case err == nil:
|
||
|
|
return reasonOK
|
||
|
|
case errors.Is(err, ErrBlocked):
|
||
|
|
return reasonBlocked
|
||
|
|
case errors.Is(err, ErrUnsupported):
|
||
|
|
return reasonUnsupported
|
||
|
|
case errors.Is(err, ErrTooLarge):
|
||
|
|
return reasonTooLarge
|
||
|
|
case errors.Is(err, ErrContentType):
|
||
|
|
return reasonContentType
|
||
|
|
case errors.Is(err, errTooManyRedirects):
|
||
|
|
return reasonRedirect
|
||
|
|
case errors.Is(err, context.Canceled):
|
||
|
|
return reasonCanceled
|
||
|
|
case errors.Is(err, context.DeadlineExceeded):
|
||
|
|
return reasonTimeout
|
||
|
|
default:
|
||
|
|
return reasonRequest
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// stripURL убирает из ошибки http.Client обёртку *url.Error: она содержит
|
||
|
|
// полный URL с query, а он не должен попадать ни в логи, ни к вызывающему
|
||
|
|
// коду. Sentinel-ошибки внутри сохраняются, errors.Is продолжает работать.
|
||
|
|
func stripURL(err error) error {
|
||
|
|
for {
|
||
|
|
var urlErr *url.Error
|
||
|
|
if !errors.As(err, &urlErr) || urlErr.Err == nil || errors.Is(urlErr.Err, err) {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
err = urlErr.Err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Normalize проверяет и нормализует URL: только http/https, без userinfo,
|
||
|
|
// хост в нижнем регистре, без фрагмента, пустой путь — "/". false — URL не
|
||
|
|
// поддержан (чужая схема, userinfo, пустой хост).
|
||
|
|
func Normalize(rawURL string) (string, bool) {
|
||
|
|
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||
|
|
if err != nil {
|
||
|
|
return "", false
|
||
|
|
}
|
||
|
|
if parsed.Opaque != "" {
|
||
|
|
// "http:example.com" и подобные формы без "//" не поддержаны.
|
||
|
|
return "", false
|
||
|
|
}
|
||
|
|
switch strings.ToLower(parsed.Scheme) {
|
||
|
|
case "http", "https":
|
||
|
|
default:
|
||
|
|
return "", false
|
||
|
|
}
|
||
|
|
if parsed.User != nil {
|
||
|
|
return "", false
|
||
|
|
}
|
||
|
|
if parsed.Host == "" || parsed.Hostname() == "" {
|
||
|
|
return "", false
|
||
|
|
}
|
||
|
|
parsed.Scheme = strings.ToLower(parsed.Scheme)
|
||
|
|
parsed.Host = strings.ToLower(parsed.Host)
|
||
|
|
parsed.Fragment = ""
|
||
|
|
parsed.RawFragment = ""
|
||
|
|
if parsed.Path == "" {
|
||
|
|
parsed.Path = "/"
|
||
|
|
}
|
||
|
|
return parsed.String(), true
|
||
|
|
}
|
||
|
|
|
||
|
|
// URLHash — sha256 (hex) от нормализованного URL: ключ кэша.
|
||
|
|
func URLHash(normalized string) string {
|
||
|
|
sum := sha256.Sum256([]byte(normalized))
|
||
|
|
return hex.EncodeToString(sum[:])
|
||
|
|
}
|
||
|
|
|
||
|
|
// control проверяет адрес, к которому реально идёт TCP-соединение. Вызывается
|
||
|
|
// net.Dialer после разрешения имени (и для каждого адреса из ответа DNS) и до
|
||
|
|
// подключения, поэтому подмена DNS между проверкой и соединением невозможна.
|
||
|
|
func (f *Fetcher) control(_, address string, _ syscall.RawConn) error {
|
||
|
|
host, _, err := net.SplitHostPort(address)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("%w: некорректный адрес подключения", ErrBlocked)
|
||
|
|
}
|
||
|
|
ip, err := netip.ParseAddr(host)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("%w: некорректный адрес подключения", ErrBlocked)
|
||
|
|
}
|
||
|
|
if blockedAddr(ip, f.allowPrivate) {
|
||
|
|
return fmt.Errorf("%w: %s", ErrBlocked, ip)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// checkRedirect проверяет каждый хоп: схему, хост и лимит переходов. Адрес
|
||
|
|
// дополнительно проверяет control при подключении к новому хосту.
|
||
|
|
func (f *Fetcher) checkRedirect(request *http.Request, via []*http.Request) error {
|
||
|
|
if len(via) > maxRedirects {
|
||
|
|
return fmt.Errorf("unfurl: %w (больше %d)", errTooManyRedirects, maxRedirects)
|
||
|
|
}
|
||
|
|
if _, ok := Normalize(request.URL.String()); !ok {
|
||
|
|
return fmt.Errorf("unfurl: %w (редирект)", ErrUnsupported)
|
||
|
|
}
|
||
|
|
if address, err := netip.ParseAddr(request.URL.Hostname()); err == nil && blockedAddr(address, f.allowPrivate) {
|
||
|
|
return fmt.Errorf("unfurl: %w (редирект)", ErrBlocked)
|
||
|
|
}
|
||
|
|
// Go копирует заголовки при переходе, но задаём их явно: так поведение
|
||
|
|
// не зависит от версии стандартной библиотеки.
|
||
|
|
request.Header.Set("User-Agent", f.userAgent)
|
||
|
|
request.Header.Set("Accept", acceptHTML)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// blockedAddr сообщает, запрещён ли адрес для подключения. allowLoopback
|
||
|
|
// разрешает только петлевые адреса — этого достаточно httptest-серверам.
|
||
|
|
// IPv4-mapped IPv6 приводятся к IPv4, поэтому ::ffff:10.0.0.1 не проскочит.
|
||
|
|
func blockedAddr(ip netip.Addr, allowLoopback bool) bool {
|
||
|
|
if !ip.IsValid() {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
ip = ip.Unmap()
|
||
|
|
if ip.Is4() {
|
||
|
|
octets := ip.As4()
|
||
|
|
switch {
|
||
|
|
case octets[0] == 127: // 127.0.0.0/8 — петля
|
||
|
|
return !allowLoopback
|
||
|
|
case octets[0] == 0: // 0.0.0.0/8, включая 0.0.0.0
|
||
|
|
return true
|
||
|
|
case octets[0] == 10: // 10.0.0.0/8
|
||
|
|
return true
|
||
|
|
case octets[0] == 172 && octets[1]&0xf0 == 16: // 172.16.0.0/12
|
||
|
|
return true
|
||
|
|
case octets[0] == 192 && octets[1] == 168: // 192.168.0.0/16
|
||
|
|
return true
|
||
|
|
case octets[0] == 169 && octets[1] == 254: // 169.254.0.0/16, метаданные облака
|
||
|
|
return true
|
||
|
|
case octets[0] == 100 && octets[1]&0xc0 == 64: // 100.64.0.0/10 (CGNAT)
|
||
|
|
return true
|
||
|
|
case octets[0] == 192 && octets[1] == 0 && octets[2] == 0: // 192.0.0.0/24
|
||
|
|
return true
|
||
|
|
case octets[0] == 198 && (octets[1] == 18 || octets[1] == 19): // 198.18.0.0/15
|
||
|
|
return true
|
||
|
|
case octets[0] >= 224: // multicast, 240.0.0.0/4 и широковещательный
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
switch {
|
||
|
|
case ip.IsLoopback(): // ::1
|
||
|
|
return !allowLoopback
|
||
|
|
case ip.IsPrivate(), // fc00::/7
|
||
|
|
ip.IsLinkLocalUnicast(), // fe80::/10
|
||
|
|
ip.IsLinkLocalMulticast(),
|
||
|
|
ip.IsInterfaceLocalMulticast(),
|
||
|
|
ip.IsMulticast(),
|
||
|
|
ip.IsUnspecified(): // ::
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
// checkContentType пропускает только HTML: charset и прочие параметры
|
||
|
|
// игнорируются, пустой или неразбираемый Content-Type — ошибка.
|
||
|
|
func checkContentType(value string) error {
|
||
|
|
if strings.TrimSpace(value) == "" {
|
||
|
|
return fmt.Errorf("%w: пустой Content-Type", ErrContentType)
|
||
|
|
}
|
||
|
|
mediaType, _, err := mime.ParseMediaType(value)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("%w: %w", ErrContentType, err)
|
||
|
|
}
|
||
|
|
switch strings.ToLower(mediaType) {
|
||
|
|
case "text/html", "application/xhtml+xml":
|
||
|
|
return nil
|
||
|
|
default:
|
||
|
|
return fmt.Errorf("%w: %s", ErrContentType, mediaType)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// sanitize убирает управляющие символы, схлопывает пробелы и переводы строк,
|
||
|
|
// обрезает значение до limit рун. Пустое значение остаётся пустым.
|
||
|
|
func sanitize(value string, limit int) string {
|
||
|
|
collapsed := strings.Map(func(r rune) rune {
|
||
|
|
switch r {
|
||
|
|
case '\t', '\n', '\v', '\f', '\r':
|
||
|
|
return ' '
|
||
|
|
}
|
||
|
|
if unicode.IsControl(r) {
|
||
|
|
return -1
|
||
|
|
}
|
||
|
|
return r
|
||
|
|
}, value)
|
||
|
|
collapsed = strings.Join(strings.Fields(collapsed), " ")
|
||
|
|
return truncateRunes(collapsed, limit)
|
||
|
|
}
|
||
|
|
|
||
|
|
// truncateRunes обрезает строку до limit рун, не разрывая UTF-8.
|
||
|
|
func truncateRunes(value string, limit int) string {
|
||
|
|
if limit <= 0 {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
count := 0
|
||
|
|
for index := range value {
|
||
|
|
if count == limit {
|
||
|
|
return value[:index]
|
||
|
|
}
|
||
|
|
count++
|
||
|
|
}
|
||
|
|
return value
|
||
|
|
}
|
||
|
|
|
||
|
|
// logDebug и logWarn пишут только хост и код причины: полный URL с query в
|
||
|
|
// логи не попадает (приватность), а уровень выбирается по исходу запроса.
|
||
|
|
func (f *Fetcher) logDebug(host, reason string) {
|
||
|
|
if f.logger == nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
f.logger.Debug("unfurl: превью разобрано", slog.String("host", host), slog.String("reason", reason))
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *Fetcher) logWarn(host, reason string) {
|
||
|
|
if f.logger == nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
f.logger.Warn("unfurl: превью недоступно", slog.String("host", host), slog.String("reason", reason))
|
||
|
|
}
|