feat(unfurl): превью ссылок с защитой от SSRF и кэшем в БД (Фаза 7)
Сервер сам загружает заголовок, описание и картинку страницы по ссылке из сообщения и отдаёт клиенту готовую карточку. Безопасность (главное здесь): - только 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 и миграция.
This commit is contained in:
@@ -58,6 +58,11 @@ VAPID_PRIVATE_KEY=
|
|||||||
# Контакт администратора для push-сервисов (mailto:admin@<домен> или https://…).
|
# Контакт администратора для push-сервисов (mailto:admin@<домен> или https://…).
|
||||||
VAPID_SUBJECT=
|
VAPID_SUBJECT=
|
||||||
|
|
||||||
|
|
||||||
|
# --- превью ссылок (Фаза 7) ---
|
||||||
|
UNFURL_ENABLED=true # сервер сам загружает превью страниц по ссылкам
|
||||||
|
UNFURL_TIMEOUT_SECONDS=5 # таймаут загрузки превью
|
||||||
|
|
||||||
AGE_RECIPIENT= # публичный age-ключ для шифрования бэкапов
|
AGE_RECIPIENT= # публичный age-ключ для шифрования бэкапов
|
||||||
BACKUP_KEEP_DAYS= # сколько дней хранить бэкапы (профиль задаёт значение)
|
BACKUP_KEEP_DAYS= # сколько дней хранить бэкапы (профиль задаёт значение)
|
||||||
BACKUP_OFFSITE_TARGET= # необязательно: rsync-цель (user@host:/path) или rclone:remote:path
|
BACKUP_OFFSITE_TARGET= # необязательно: rsync-цель (user@host:/path) или rclone:remote:path
|
||||||
|
|||||||
+2
-2
@@ -403,9 +403,9 @@ func cliCleanup(args []string) int {
|
|||||||
}
|
}
|
||||||
stats, err := retention.New(st, options).RunOnce(ctx)
|
stats, err := retention.New(st, options).RunOnce(ctx)
|
||||||
fmt.Printf("удалено: сессий %d, записей аудита %d, файлов %d, файлов без записи %d, "+
|
fmt.Printf("удалено: сессий %d, записей аудита %d, файлов %d, файлов без записи %d, "+
|
||||||
"подписок Web Push %d (%d байт)\n",
|
"подписок Web Push %d, превью ссылок %d (%d байт)\n",
|
||||||
stats.Sessions, stats.Audit, stats.Files, stats.Unknown,
|
stats.Sessions, stats.Audit, stats.Files, stats.Unknown,
|
||||||
stats.PushSubscriptions, stats.Bytes)
|
stats.PushSubscriptions, stats.LinkPreviews, stats.Bytes)
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ type Config struct {
|
|||||||
// (mailto:… или https://…). Пустой ключ — Web Push выключен.
|
// (mailto:… или https://…). Пустой ключ — Web Push выключен.
|
||||||
VAPIDPrivateKey string
|
VAPIDPrivateKey string
|
||||||
VAPIDSubject string
|
VAPIDSubject string
|
||||||
|
// Unfurl ссылок (AGENT.md Фаза 7): сервер сам загружает превью страниц по
|
||||||
|
// ссылкам из сообщений. Выключенный unfurl не ходит в интернет вовсе.
|
||||||
|
UnfurlEnabled bool
|
||||||
|
UnfurlTimeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
@@ -127,6 +131,9 @@ func Load() (Config, error) {
|
|||||||
|
|
||||||
VAPIDPrivateKey: env("VAPID_PRIVATE_KEY", ""),
|
VAPIDPrivateKey: env("VAPID_PRIVATE_KEY", ""),
|
||||||
VAPIDSubject: env("VAPID_SUBJECT", ""),
|
VAPIDSubject: env("VAPID_SUBJECT", ""),
|
||||||
|
|
||||||
|
UnfurlEnabled: envBool("UNFURL_ENABLED", true),
|
||||||
|
UnfurlTimeout: time.Duration(envInt("UNFURL_TIMEOUT_SECONDS", 5)) * time.Second,
|
||||||
}
|
}
|
||||||
if cfg.FilesDomain == "" {
|
if cfg.FilesDomain == "" {
|
||||||
cfg.FilesDomain = "files." + cfg.Domain
|
cfg.FilesDomain = "files." + cfg.Domain
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Фаза 7: кэш серверных превью ссылок (unfurl).
|
||||||
|
--
|
||||||
|
-- Ключ — sha256 (hex) от нормализованного URL: сам URL длинный и с query,
|
||||||
|
-- а сравнивать нужно каноническую форму (internal/unfurl.Normalize).
|
||||||
|
-- status: ok | empty | blocked | error. Истёкшие записи не отдаются
|
||||||
|
-- (expires_at > now) и удаляются фоновой уборкой.
|
||||||
|
CREATE TABLE link_previews (
|
||||||
|
url_hash TEXT PRIMARY KEY,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
site_name TEXT NOT NULL DEFAULT '',
|
||||||
|
image_url TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
fetched_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX link_previews_expires_idx ON link_previews (expires_at);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE link_previews;
|
||||||
@@ -11,6 +11,7 @@ type Features struct {
|
|||||||
// VoiceURL — адрес LiveKit для клиента (ws/wss), пусто при выключенном голосе.
|
// VoiceURL — адрес LiveKit для клиента (ws/wss), пусто при выключенном голосе.
|
||||||
VoiceURL string `json:"voice_url,omitempty"`
|
VoiceURL string `json:"voice_url,omitempty"`
|
||||||
WebPushEnabled bool `json:"web_push_enabled"`
|
WebPushEnabled bool `json:"web_push_enabled"`
|
||||||
|
UnfurlEnabled bool `json:"unfurl_enabled"`
|
||||||
OAuthEnabled bool `json:"oauth_enabled"`
|
OAuthEnabled bool `json:"oauth_enabled"`
|
||||||
PasskeysEnabled bool `json:"passkeys_enabled"`
|
PasskeysEnabled bool `json:"passkeys_enabled"`
|
||||||
// OAuthProviders — включённые провайдеры входа (id): клиент рисует по
|
// OAuthProviders — включённые провайдеры входа (id): клиент рисует по
|
||||||
@@ -50,6 +51,7 @@ func New(cfg config.Config) Response {
|
|||||||
VoiceEnabled: cfg.VoiceEnabled(),
|
VoiceEnabled: cfg.VoiceEnabled(),
|
||||||
VoiceURL: cfg.LiveKitURL,
|
VoiceURL: cfg.LiveKitURL,
|
||||||
WebPushEnabled: cfg.WebPushEnabled(),
|
WebPushEnabled: cfg.WebPushEnabled(),
|
||||||
|
UnfurlEnabled: cfg.UnfurlEnabled,
|
||||||
OAuthEnabled: cfg.OAuthEnabled(),
|
OAuthEnabled: cfg.OAuthEnabled(),
|
||||||
PasskeysEnabled: cfg.WebAuthnEnabled(),
|
PasskeysEnabled: cfg.WebAuthnEnabled(),
|
||||||
OAuthProviders: cfg.OAuthProviders(),
|
OAuthProviders: cfg.OAuthProviders(),
|
||||||
|
|||||||
@@ -42,10 +42,12 @@ type Stats struct {
|
|||||||
Bytes int64
|
Bytes int64
|
||||||
// PushSubscriptions — мёртвые подписки Web Push (Фаза 7).
|
// PushSubscriptions — мёртвые подписки Web Push (Фаза 7).
|
||||||
PushSubscriptions int64
|
PushSubscriptions int64
|
||||||
|
// LinkPreviews — истёкшие записи кэша превью ссылок (Фаза 7).
|
||||||
|
LinkPreviews int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Мёртвые подписки Web Push (Фаза 7): подписка, которая не доставлялась
|
// Мёртвые подписки Web Push и кэш превью ссылок (Фаза 7): подписка, которая
|
||||||
// неделю и накопила неудачи, больше не нужна.
|
// не доставлялась неделю и накопила неудачи, и протухшее превью не нужны.
|
||||||
const (
|
const (
|
||||||
stalePushAfterDays = 7
|
stalePushAfterDays = 7
|
||||||
stalePushMaxFailures = 5
|
stalePushMaxFailures = 5
|
||||||
@@ -136,6 +138,13 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
|
|||||||
}
|
}
|
||||||
stats.PushSubscriptions = removed
|
stats.PushSubscriptions = removed
|
||||||
|
|
||||||
|
// 6. Истёкший кэш превью ссылок: перезапрашивать его незачем.
|
||||||
|
previews, err := r.store.DeleteExpiredLinkPreviews(ctx, r.now())
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("link previews: %w", err))
|
||||||
|
}
|
||||||
|
stats.LinkPreviews = previews
|
||||||
|
|
||||||
stats2 := stats
|
stats2 := stats
|
||||||
if len(errs) > 0 {
|
if len(errs) > 0 {
|
||||||
return stats2, errors.Join(errs...)
|
return stats2, errors.Join(errs...)
|
||||||
@@ -254,6 +263,7 @@ func (r *Runner) Run(ctx context.Context, interval time.Duration) func() {
|
|||||||
slog.Int64("files", stats.Files),
|
slog.Int64("files", stats.Files),
|
||||||
slog.Int64("unknown_files", stats.Unknown),
|
slog.Int64("unknown_files", stats.Unknown),
|
||||||
slog.Int64("push_subscriptions", stats.PushSubscriptions),
|
slog.Int64("push_subscriptions", stats.PushSubscriptions),
|
||||||
|
slog.Int64("link_previews", stats.LinkPreviews),
|
||||||
slog.Int64("bytes", stats.Bytes))
|
slog.Int64("bytes", stats.Bytes))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ func newPushTestServer(t *testing.T, withKeys bool) (*Server, *store.Store) {
|
|||||||
Argon2Parallelism: 1,
|
Argon2Parallelism: 1,
|
||||||
LogLevel: "error",
|
LogLevel: "error",
|
||||||
LogFormat: "json",
|
LogFormat: "json",
|
||||||
|
UnfurlEnabled: true,
|
||||||
|
UnfurlTimeout: 5 * time.Second,
|
||||||
}
|
}
|
||||||
if withKeys {
|
if withKeys {
|
||||||
cfg.VAPIDPrivateKey = testVAPIDPrivateKey(t)
|
cfg.VAPIDPrivateKey = testVAPIDPrivateKey(t)
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/danielgtaylor/huma/v2"
|
||||||
|
|
||||||
|
"glchat/internal/config"
|
||||||
|
"glchat/internal/store"
|
||||||
|
"glchat/internal/unfurl"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Unfurl ссылок (AGENT.md Фаза 7): сервер загружает заголовок, описание и
|
||||||
|
// картинку страницы по ссылке из сообщения и отдаёт клиенту карточку превью.
|
||||||
|
//
|
||||||
|
// Безопасность важнее полноты: загрузка идёт через internal/unfurl (только
|
||||||
|
// http/https, запрет внутренних адресов с проверкой на подключении, лимиты
|
||||||
|
// времени и размера, только HTML). Картинка на сервер не скачивается — клиент
|
||||||
|
// берёт её по проверенному URL с `referrerpolicy=no-referrer`.
|
||||||
|
//
|
||||||
|
// Кэш живёт в БД (`link_previews`): успешные превью — на сутки, «пустые» и
|
||||||
|
// запрещённые — на час, сбои — на десять минут, чтобы не долбить чужой сайт.
|
||||||
|
|
||||||
|
// Время жизни записей кэша по статусу.
|
||||||
|
const (
|
||||||
|
unfurlTTLOK = 24 * time.Hour
|
||||||
|
unfurlTTLEmpty = time.Hour
|
||||||
|
unfurlTTLBlocked = time.Hour
|
||||||
|
unfurlTTLError = 10 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// linkPreviewer загружает и кэширует превью ссылок.
|
||||||
|
type linkPreviewer struct {
|
||||||
|
enabled bool
|
||||||
|
fetcher *unfurl.Fetcher
|
||||||
|
store *store.Store
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// newLinkPreviewer собирает загрузчик по конфигу: выключенный unfurl не
|
||||||
|
// создаёт ни клиента, ни фоновых задач.
|
||||||
|
func newLinkPreviewer(cfg config.Config, st *store.Store, logger *slog.Logger) *linkPreviewer {
|
||||||
|
return &linkPreviewer{
|
||||||
|
enabled: cfg.UnfurlEnabled,
|
||||||
|
fetcher: unfurl.NewFetcher(unfurl.Options{
|
||||||
|
Timeout: cfg.UnfurlTimeout,
|
||||||
|
Logger: logger,
|
||||||
|
}),
|
||||||
|
store: st,
|
||||||
|
now: func() time.Time { return time.Now().UTC() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkPreviewPayload — карточка превью для клиента.
|
||||||
|
type linkPreviewPayload struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
SiteName string `json:"site_name,omitempty"`
|
||||||
|
ImageURL string `json:"image_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type linkPreviewOutput struct {
|
||||||
|
Body struct {
|
||||||
|
// Status: ok (карточка есть), empty (метаданных нет), blocked (адрес
|
||||||
|
// запрещён), error (не удалось загрузить). Карточка показывается
|
||||||
|
// только при ok.
|
||||||
|
Status string `json:"status"`
|
||||||
|
Preview *linkPreviewPayload `json:"preview,omitempty"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerUnfurlRoutes описывает ручку превью ссылок.
|
||||||
|
func (s *Server) registerUnfurlRoutes(api huma.API) {
|
||||||
|
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "getLinkPreview",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/link-previews",
|
||||||
|
Summary: "Карточка превью по ссылке из сообщения",
|
||||||
|
Tags: []string{"Unfurl"},
|
||||||
|
Security: security,
|
||||||
|
}, func(ctx context.Context, input *struct {
|
||||||
|
URL string `query:"url" required:"true" maxLength:"2048"`
|
||||||
|
},
|
||||||
|
) (*linkPreviewOutput, error) {
|
||||||
|
user, _, err := requireUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !s.unfurl.enabled {
|
||||||
|
return nil, humaErrorStatus(http.StatusServiceUnavailable, "unfurl.disabled",
|
||||||
|
"link previews are disabled on this instance")
|
||||||
|
}
|
||||||
|
output := &linkPreviewOutput{}
|
||||||
|
status, preview, err := s.linkPreview(ctx, user.ID, input.URL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
output.Body.Status = status
|
||||||
|
output.Body.Preview = preview
|
||||||
|
return output, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkPreview отдаёт превью из кэша или загружает его. Статусы совпадают с
|
||||||
|
// тем, что видит клиент: ok, empty, blocked, error.
|
||||||
|
func (s *Server) linkPreview(ctx context.Context, userID uint64, rawURL string) (string, *linkPreviewPayload, error) {
|
||||||
|
normalized, ok := unfurl.Normalize(rawURL)
|
||||||
|
if !ok {
|
||||||
|
// Сюда попадают не только «плохие» адреса, но и file://, data: и
|
||||||
|
// прочие схемы: клиент карточку не показывает, ошибки не показывает тем
|
||||||
|
// более — просто «нет превью».
|
||||||
|
return store.LinkPreviewStatusBlocked, nil, nil
|
||||||
|
}
|
||||||
|
key := unfurl.URLHash(normalized)
|
||||||
|
|
||||||
|
cached, err := s.store.GetLinkPreview(ctx, key)
|
||||||
|
if err == nil {
|
||||||
|
return linkPreviewFromCache(cached)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
return "", nil, humaError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Новый запрос к чужому сайту — только под лимитом: кэш повторные
|
||||||
|
// обращения не считает, иначе один пользователь может гонять чужие
|
||||||
|
// страницы пачками (AGENT.md 8.6).
|
||||||
|
if allowed, retryAfter := s.unfurlLimiter.Allow(pushLimitKey(userID)); !allowed {
|
||||||
|
return "", nil, rateLimitedError(retryAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
preview, fetchErr := s.unfurl.fetcher.Fetch(ctx, normalized)
|
||||||
|
status := store.LinkPreviewStatusError
|
||||||
|
switch {
|
||||||
|
case fetchErr == nil && preview.Empty():
|
||||||
|
status = store.LinkPreviewStatusEmpty
|
||||||
|
case fetchErr == nil:
|
||||||
|
status = store.LinkPreviewStatusOK
|
||||||
|
case errors.Is(fetchErr, unfurl.ErrBlocked), errors.Is(fetchErr, unfurl.ErrUnsupported):
|
||||||
|
status = store.LinkPreviewStatusBlocked
|
||||||
|
}
|
||||||
|
if fetchErr != nil && status == store.LinkPreviewStatusError {
|
||||||
|
s.logger.WarnContext(ctx, "link preview failed",
|
||||||
|
slog.String("host", hostOf(normalized)), slog.Any("error", fetchErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ошибку кэша не поднимаем наверх: превью уже получено (или честно
|
||||||
|
// отсутствует), а запись в кэш — оптимизация.
|
||||||
|
now := s.unfurl.now()
|
||||||
|
params := store.SaveLinkPreviewParams{
|
||||||
|
URLHash: key,
|
||||||
|
URL: normalized,
|
||||||
|
Title: preview.Title,
|
||||||
|
Description: preview.Description,
|
||||||
|
SiteName: preview.SiteName,
|
||||||
|
ImageURL: preview.ImageURL,
|
||||||
|
Status: status,
|
||||||
|
FetchedAt: now,
|
||||||
|
ExpiresAt: now.Add(unfurlTTLFor(status)),
|
||||||
|
}
|
||||||
|
if err := s.store.SaveLinkPreview(ctx, params); err != nil {
|
||||||
|
s.logger.WarnContext(ctx, "link preview cache write failed", slog.Any("error", err))
|
||||||
|
}
|
||||||
|
if status != store.LinkPreviewStatusOK {
|
||||||
|
return status, nil, nil
|
||||||
|
}
|
||||||
|
return status, &linkPreviewPayload{
|
||||||
|
URL: normalized,
|
||||||
|
Title: preview.Title,
|
||||||
|
Description: preview.Description,
|
||||||
|
SiteName: preview.SiteName,
|
||||||
|
ImageURL: preview.ImageURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkPreviewFromCache переводит запись кэша в ответ клиенту.
|
||||||
|
func linkPreviewFromCache(cached *store.LinkPreview) (string, *linkPreviewPayload, error) {
|
||||||
|
if cached.Status != store.LinkPreviewStatusOK {
|
||||||
|
return cached.Status, nil, nil
|
||||||
|
}
|
||||||
|
return cached.Status, &linkPreviewPayload{
|
||||||
|
URL: cached.URL,
|
||||||
|
Title: cached.Title,
|
||||||
|
Description: cached.Description,
|
||||||
|
SiteName: cached.SiteName,
|
||||||
|
ImageURL: cached.ImageURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unfurlTTLFor выбирает срок жизни записи кэша по статусу.
|
||||||
|
func unfurlTTLFor(status string) time.Duration {
|
||||||
|
switch status {
|
||||||
|
case store.LinkPreviewStatusOK:
|
||||||
|
return unfurlTTLOK
|
||||||
|
case store.LinkPreviewStatusEmpty:
|
||||||
|
return unfurlTTLEmpty
|
||||||
|
case store.LinkPreviewStatusBlocked:
|
||||||
|
return unfurlTTLBlocked
|
||||||
|
default:
|
||||||
|
return unfurlTTLError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostOf достаёт хост для логов: полный URL с query туда не попадает.
|
||||||
|
func hostOf(normalized string) string {
|
||||||
|
parsed, err := url.Parse(normalized)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parsed.Hostname()
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"glchat/internal/store"
|
||||||
|
"glchat/internal/unfurl"
|
||||||
|
)
|
||||||
|
|
||||||
|
// withUnfurl подменяет загрузчик превью на тестовый: httptest-серверы живут
|
||||||
|
// на петле, поэтому AllowPrivate разрешён только здесь.
|
||||||
|
func withUnfurl(t *testing.T, srv *Server, st *store.Store) {
|
||||||
|
t.Helper()
|
||||||
|
srv.unfurl = &linkPreviewer{
|
||||||
|
enabled: true,
|
||||||
|
fetcher: unfurl.NewFetcher(unfurl.Options{AllowPrivate: true}),
|
||||||
|
store: st,
|
||||||
|
now: func() time.Time { return time.Now().UTC() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// htmlServer отдаёт страницу с метаданными Open Graph.
|
||||||
|
func htmlServer(t *testing.T, body string) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkPreviewReturnsCardAndCaches(t *testing.T) {
|
||||||
|
srv, st := newPushTestServer(t, true)
|
||||||
|
withUnfurl(t, srv, st)
|
||||||
|
cookie := registerAndLogin(t, srv, "unfurl_user", "unfurl@example.com")
|
||||||
|
|
||||||
|
page := htmlServer(t, `<!doctype html><html><head>
|
||||||
|
<meta property="og:title" content="Заголовок страницы">
|
||||||
|
<meta property="og:description" content="Краткое описание">
|
||||||
|
<meta property="og:site_name" content="Example">
|
||||||
|
<meta property="og:image" content="/cover.png">
|
||||||
|
</head><body>текст</body></html>`)
|
||||||
|
|
||||||
|
rec := doJSON(t, srv, http.MethodGet,
|
||||||
|
"/api/v1/link-previews?url="+urlQueryEscape(page.URL+"/page?utm=1"), "", cookie)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("превью = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
payload := decodeResponse[struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Preview struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
SiteName string `json:"site_name"`
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
} `json:"preview"`
|
||||||
|
}](t, rec)
|
||||||
|
if payload.Status != store.LinkPreviewStatusOK {
|
||||||
|
t.Fatalf("статус = %q, body = %s", payload.Status, rec.Body.String())
|
||||||
|
}
|
||||||
|
if payload.Preview.Title != "Заголовок страницы" || payload.Preview.Description != "Краткое описание" {
|
||||||
|
t.Fatalf("неожиданная карточка: %+v", payload.Preview)
|
||||||
|
}
|
||||||
|
if payload.Preview.SiteName != "Example" {
|
||||||
|
t.Fatalf("site_name = %q", payload.Preview.SiteName)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(payload.Preview.ImageURL, page.URL) {
|
||||||
|
t.Fatalf("относительная картинка не развёрнута: %q", payload.Preview.ImageURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Второй запрос обслуживается кэшем: страница уже недоступна.
|
||||||
|
page.Close()
|
||||||
|
rec = doJSON(t, srv, http.MethodGet,
|
||||||
|
"/api/v1/link-previews?url="+urlQueryEscape(page.URL+"/page?utm=1"), "", cookie)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("повторный запрос = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
cached := decodeResponse[struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Preview struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
} `json:"preview"`
|
||||||
|
}](t, rec)
|
||||||
|
if cached.Status != store.LinkPreviewStatusOK || cached.Preview.Title != "Заголовок страницы" {
|
||||||
|
t.Fatalf("кэш не отдал превью: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkPreviewEmptyPage(t *testing.T) {
|
||||||
|
srv, st := newPushTestServer(t, true)
|
||||||
|
withUnfurl(t, srv, st)
|
||||||
|
cookie := registerAndLogin(t, srv, "unfurl_empty", "unfurl-empty@example.com")
|
||||||
|
|
||||||
|
page := htmlServer(t, `<!doctype html><html><head><title></title></head><body>нет метаданных</body></html>`)
|
||||||
|
rec := doJSON(t, srv, http.MethodGet,
|
||||||
|
"/api/v1/link-previews?url="+urlQueryEscape(page.URL), "", cookie)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("ответ = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
payload := decodeResponse[struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Preview any `json:"preview"`
|
||||||
|
}](t, rec)
|
||||||
|
// <title></title> пустой: карточки нет.
|
||||||
|
if payload.Status != store.LinkPreviewStatusEmpty || payload.Preview != nil {
|
||||||
|
t.Fatalf("ожидался empty без карточки: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkPreviewRejectsInternalAndUnsupported(t *testing.T) {
|
||||||
|
srv, st := newPushTestServer(t, true)
|
||||||
|
withUnfurl(t, srv, st)
|
||||||
|
cookie := registerAndLogin(t, srv, "unfurl_bad", "unfurl-bad@example.com")
|
||||||
|
|
||||||
|
// Проверяем боевую конфигурацию: AllowPrivate в тестовом загрузчике
|
||||||
|
// разрешает петлю, поэтому здесь собираем загрузчик как в продакшене.
|
||||||
|
srv.unfurl.fetcher = unfurl.NewFetcher(unfurl.Options{})
|
||||||
|
|
||||||
|
cases := map[string]string{
|
||||||
|
"метаданные облака": "https://169.254.169.254/latest/meta-data/",
|
||||||
|
"частная сеть": "https://10.1.2.3/secret",
|
||||||
|
"петля": "https://127.0.0.1:8080/api/v1/meta",
|
||||||
|
"file": "file:///etc/passwd",
|
||||||
|
"javascript": "javascript:alert(1)",
|
||||||
|
"data": "data:text/html,<h1>x</h1>",
|
||||||
|
}
|
||||||
|
for name, target := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
rec := doJSON(t, srv, http.MethodGet,
|
||||||
|
"/api/v1/link-previews?url="+urlQueryEscape(target), "", cookie)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("ответ = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
payload := decodeResponse[struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Preview any `json:"preview"`
|
||||||
|
}](t, rec)
|
||||||
|
if payload.Status != store.LinkPreviewStatusBlocked {
|
||||||
|
t.Fatalf("статус = %q, ожидался blocked (%s)", payload.Status, rec.Body.String())
|
||||||
|
}
|
||||||
|
if payload.Preview != nil {
|
||||||
|
t.Fatalf("карточка не должна отдаваться: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkPreviewNonHTMLIsError(t *testing.T) {
|
||||||
|
srv, st := newPushTestServer(t, true)
|
||||||
|
withUnfurl(t, srv, st)
|
||||||
|
cookie := registerAndLogin(t, srv, "unfurl_json", "unfurl-json@example.com")
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"title":"я не страница"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
rec := doJSON(t, srv, http.MethodGet,
|
||||||
|
"/api/v1/link-previews?url="+urlQueryEscape(server.URL), "", cookie)
|
||||||
|
payload := decodeResponse[struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}](t, rec)
|
||||||
|
if payload.Status != store.LinkPreviewStatusError {
|
||||||
|
t.Fatalf("статус = %q, ожидался error (%s)", payload.Status, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkPreviewDisabled(t *testing.T) {
|
||||||
|
srv, _ := newPushTestServer(t, true)
|
||||||
|
srv.unfurl.enabled = false
|
||||||
|
cookie := registerAndLogin(t, srv, "unfurl_off", "unfurl-off@example.com")
|
||||||
|
|
||||||
|
rec := doJSON(t, srv, http.MethodGet,
|
||||||
|
"/api/v1/link-previews?url=https%3A%2F%2Fexample.com%2F", "", cookie)
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("ответ = %d, ожидался 503", rec.Code)
|
||||||
|
}
|
||||||
|
if code := errorCodeOf(t, rec); code != "unfurl.disabled" {
|
||||||
|
t.Fatalf("код ошибки = %q", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkPreviewRequiresSession(t *testing.T) {
|
||||||
|
srv, _ := newPushTestServer(t, true)
|
||||||
|
rec := doJSON(t, srv, http.MethodGet,
|
||||||
|
"/api/v1/link-previews?url=https%3A%2F%2Fexample.com%2F", "")
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("ответ = %d, ожидался 401", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кэш не расходует лимит, а новые загрузки — расходуют: иначе один клиент
|
||||||
|
// может гонять чужие страницы пачками.
|
||||||
|
func TestLinkPreviewRateLimit(t *testing.T) {
|
||||||
|
srv, st := newPushTestServer(t, true)
|
||||||
|
withUnfurl(t, srv, st)
|
||||||
|
cookie := registerAndLogin(t, srv, "unfurl_flood", "unfurl-flood@example.com")
|
||||||
|
|
||||||
|
page := htmlServer(t, `<html><head><meta property="og:title" content="t"></head></html>`)
|
||||||
|
limited := false
|
||||||
|
for i := 0; i < 40; i++ {
|
||||||
|
target := page.URL + "/page-" + itoa(i)
|
||||||
|
rec := doJSON(t, srv, http.MethodGet,
|
||||||
|
"/api/v1/link-previews?url="+urlQueryEscape(target), "", cookie)
|
||||||
|
if rec.Code == http.StatusTooManyRequests {
|
||||||
|
limited = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !limited {
|
||||||
|
t.Fatal("лимит новых загрузок превью не сработал")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetaExposesUnfurlFlag(t *testing.T) {
|
||||||
|
srv, _ := newPushTestServer(t, true)
|
||||||
|
rec := doJSON(t, srv, http.MethodGet, "/api/v1/meta", "")
|
||||||
|
payload := decodeResponse[struct {
|
||||||
|
Features struct {
|
||||||
|
UnfurlEnabled bool `json:"unfurl_enabled"`
|
||||||
|
} `json:"features"`
|
||||||
|
}](t, rec)
|
||||||
|
if !payload.Features.UnfurlEnabled {
|
||||||
|
t.Fatal("meta не сообщает о включённом unfurl")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// urlQueryEscape кодирует URL для query-параметра.
|
||||||
|
func urlQueryEscape(value string) string { return url.QueryEscape(value) }
|
||||||
|
|
||||||
|
// itoa нужен для уникальных адресов страниц в тесте лимита.
|
||||||
|
func itoa(value int) string { return strconv.Itoa(value) }
|
||||||
@@ -78,6 +78,10 @@ type Server struct {
|
|||||||
pushNotifyLimiter *httpx.RateLimiter
|
pushNotifyLimiter *httpx.RateLimiter
|
||||||
// push отправляет Web Push; nil, если ключи не настроены.
|
// push отправляет Web Push; nil, если ключи не настроены.
|
||||||
push pushSender
|
push pushSender
|
||||||
|
// unfurl загружает и кэширует превью ссылок (Фаза 7), unfurlLimiter
|
||||||
|
// ограничивает новые загрузки (кэш лимит не расходует).
|
||||||
|
unfurl *linkPreviewer
|
||||||
|
unfurlLimiter *httpx.RateLimiter
|
||||||
// slowmode — время последней отправки в комнату для режима медленной
|
// slowmode — время последней отправки в комнату для режима медленной
|
||||||
// отправки; словарь ограничен по размеру (AGENT.md 7.5).
|
// отправки; словарь ограничен по размеру (AGENT.md 7.5).
|
||||||
slowmodeMu sync.Mutex
|
slowmodeMu sync.Mutex
|
||||||
@@ -138,6 +142,9 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
|||||||
passkeyLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
passkeyLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
||||||
oauthLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
oauthLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
||||||
pushLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
pushLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
||||||
|
// Новые превью ссылок: 20 в минуту на пользователя (кэшированные
|
||||||
|
// ответы лимит не тратят).
|
||||||
|
unfurlLimiter: httpx.NewRateLimiterWindow(20, time.Minute, 20),
|
||||||
// Уведомления на получателя: 20 в минуту с запасом 20 — массовые
|
// Уведомления на получателя: 20 в минуту с запасом 20 — массовые
|
||||||
// упоминания не должны заваливать устройство.
|
// упоминания не должны заваливать устройство.
|
||||||
pushNotifyLimiter: httpx.NewRateLimiter(20, 20),
|
pushNotifyLimiter: httpx.NewRateLimiter(20, 20),
|
||||||
@@ -158,6 +165,11 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
|||||||
case deps.Store != nil:
|
case deps.Store != nil:
|
||||||
s.perms = permissions.NewCalculator(source.New(deps.Store))
|
s.perms = permissions.NewCalculator(source.New(deps.Store))
|
||||||
}
|
}
|
||||||
|
// Превью ссылок (Фаза 7): загрузчик создаётся всегда, а выключенный
|
||||||
|
// unfurl просто отвечает `unfurl.disabled` и в интернет не ходит.
|
||||||
|
if deps.Store != nil {
|
||||||
|
s.unfurl = newLinkPreviewer(cfg, deps.Store, logger)
|
||||||
|
}
|
||||||
// Web Push (Фаза 7): неверный VAPID-ключ не должен мешать инстансу
|
// Web Push (Фаза 7): неверный VAPID-ключ не должен мешать инстансу
|
||||||
// подняться — пишем предупреждение и работаем без push.
|
// подняться — пишем предупреждение и работаем без push.
|
||||||
if deps.Store != nil {
|
if deps.Store != nil {
|
||||||
@@ -204,6 +216,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
|||||||
s.registerInviteBackgroundRoutes(apiRouter)
|
s.registerInviteBackgroundRoutes(apiRouter)
|
||||||
s.registerVoiceWebhook(apiRouter)
|
s.registerVoiceWebhook(apiRouter)
|
||||||
s.registerPushRoutes(s.api)
|
s.registerPushRoutes(s.api)
|
||||||
|
s.registerUnfurlRoutes(s.api)
|
||||||
}
|
}
|
||||||
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LinkPreview — кэшированное превью ссылки (AGENT.md, фаза 7: unfurl).
|
||||||
|
// URLHash — sha256 (hex) от нормализованного URL, он же ключ таблицы.
|
||||||
|
type LinkPreview struct {
|
||||||
|
URLHash string
|
||||||
|
URL string
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
SiteName string
|
||||||
|
ImageURL string
|
||||||
|
Status string
|
||||||
|
FetchedAt string
|
||||||
|
ExpiresAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Статусы превью. ok — метаданные найдены, empty — страница без них,
|
||||||
|
// blocked — адрес запрещён, error — загрузка или разбор не удались.
|
||||||
|
const (
|
||||||
|
LinkPreviewStatusOK = "ok"
|
||||||
|
LinkPreviewStatusEmpty = "empty"
|
||||||
|
LinkPreviewStatusBlocked = "blocked"
|
||||||
|
LinkPreviewStatusError = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultLinkPreviewTTL — запасной срок жизни записи, если вызывающий код
|
||||||
|
// не задал ExpiresAt: нулевая метка выглядела бы как уже истёкшая.
|
||||||
|
const defaultLinkPreviewTTL = 24 * time.Hour
|
||||||
|
|
||||||
|
// SaveLinkPreviewParams — параметры сохранения превью. Метки времени
|
||||||
|
// хранятся в том же формате, что и в остальных таблицах (s.Timestamp).
|
||||||
|
type SaveLinkPreviewParams struct {
|
||||||
|
URLHash string
|
||||||
|
URL string
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
SiteName string
|
||||||
|
ImageURL string
|
||||||
|
Status string
|
||||||
|
FetchedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkPreviewColumns = `url_hash, url, title, description, site_name, image_url, status, fetched_at, expires_at`
|
||||||
|
|
||||||
|
// GetLinkPreview отдаёт превью по хэшу нормализованного URL. Истёкшие записи
|
||||||
|
// считаются отсутствующими (ErrNotFound): их удаляет DeleteExpiredLinkPreviews.
|
||||||
|
func (s *Store) GetLinkPreview(ctx context.Context, urlHash string) (*LinkPreview, error) {
|
||||||
|
row := s.reader.QueryRowContext(ctx, `
|
||||||
|
SELECT `+linkPreviewColumns+` FROM link_previews
|
||||||
|
WHERE url_hash = ? AND expires_at > ?`, urlHash, s.Now())
|
||||||
|
return scanLinkPreview(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveLinkPreview сохраняет превью, перезаписывая запись с тем же url_hash
|
||||||
|
// (upsert): повторная загрузка той же ссылки обновляет кэш.
|
||||||
|
func (s *Store) SaveLinkPreview(ctx context.Context, params SaveLinkPreviewParams) error {
|
||||||
|
if params.Status == "" {
|
||||||
|
params.Status = LinkPreviewStatusError
|
||||||
|
}
|
||||||
|
fetchedAt := params.FetchedAt
|
||||||
|
if fetchedAt.IsZero() {
|
||||||
|
fetchedAt = s.now()
|
||||||
|
}
|
||||||
|
expiresAt := params.ExpiresAt
|
||||||
|
if expiresAt.IsZero() {
|
||||||
|
expiresAt = fetchedAt.Add(defaultLinkPreviewTTL)
|
||||||
|
}
|
||||||
|
_, err := s.writer.ExecContext(ctx, `
|
||||||
|
INSERT INTO link_previews
|
||||||
|
(url_hash, url, title, description, site_name, image_url, status, fetched_at, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (url_hash) DO UPDATE SET
|
||||||
|
url = excluded.url,
|
||||||
|
title = excluded.title,
|
||||||
|
description = excluded.description,
|
||||||
|
site_name = excluded.site_name,
|
||||||
|
image_url = excluded.image_url,
|
||||||
|
status = excluded.status,
|
||||||
|
fetched_at = excluded.fetched_at,
|
||||||
|
expires_at = excluded.expires_at`,
|
||||||
|
params.URLHash, params.URL, params.Title, params.Description, params.SiteName,
|
||||||
|
params.ImageURL, params.Status, s.Timestamp(fetchedAt), s.Timestamp(expiresAt))
|
||||||
|
if err != nil {
|
||||||
|
return mapError(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteExpiredLinkPreviews удаляет записи, срок жизни которых истёк до
|
||||||
|
// before, и возвращает число удалённых строк (фоновая уборка кэша).
|
||||||
|
func (s *Store) DeleteExpiredLinkPreviews(ctx context.Context, before time.Time) (int64, error) {
|
||||||
|
result, err := s.writer.ExecContext(ctx,
|
||||||
|
`DELETE FROM link_previews WHERE expires_at <= ?`, s.Timestamp(before))
|
||||||
|
if err != nil {
|
||||||
|
return 0, mapError(err)
|
||||||
|
}
|
||||||
|
deleted, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("count deleted link previews: %w", err)
|
||||||
|
}
|
||||||
|
return deleted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanLinkPreview(scanner interface{ Scan(...any) error }) (*LinkPreview, error) {
|
||||||
|
var preview LinkPreview
|
||||||
|
err := scanner.Scan(&preview.URLHash, &preview.URL, &preview.Title, &preview.Description,
|
||||||
|
&preview.SiteName, &preview.ImageURL, &preview.Status, &preview.FetchedAt, &preview.ExpiresAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapError(err)
|
||||||
|
}
|
||||||
|
return &preview, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"glchat/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newLinkPreviewTestStore открывает временную БД со всеми миграциями.
|
||||||
|
// В пакете store это первый тест, поэтому помощник живёт здесь.
|
||||||
|
func newLinkPreviewTestStore(t *testing.T) *Store {
|
||||||
|
t.Helper()
|
||||||
|
db, err := database.Open(context.Background(), database.Options{
|
||||||
|
Path: filepath.Join(t.TempDir(), "glchat.db"),
|
||||||
|
ReadPool: 2,
|
||||||
|
Migrate: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open test database: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
t.Errorf("close test database: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return New(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkPreviewSaveAndGet(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st := newLinkPreviewTestStore(t)
|
||||||
|
|
||||||
|
fetchedAt := time.Now().UTC()
|
||||||
|
params := SaveLinkPreviewParams{
|
||||||
|
URLHash: "hash-1",
|
||||||
|
URL: "https://example.com/page",
|
||||||
|
Title: "Заголовок",
|
||||||
|
Description: "Описание",
|
||||||
|
SiteName: "Пример",
|
||||||
|
ImageURL: "https://example.com/pic.png",
|
||||||
|
Status: LinkPreviewStatusOK,
|
||||||
|
FetchedAt: fetchedAt,
|
||||||
|
ExpiresAt: fetchedAt.Add(time.Hour),
|
||||||
|
}
|
||||||
|
if err := st.SaveLinkPreview(ctx, params); err != nil {
|
||||||
|
t.Fatalf("SaveLinkPreview() вернул ошибку: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
preview, err := st.GetLinkPreview(ctx, params.URLHash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLinkPreview() вернул ошибку: %v", err)
|
||||||
|
}
|
||||||
|
if preview.URLHash != params.URLHash || preview.URL != params.URL {
|
||||||
|
t.Errorf("URLHash/URL = %q/%q, want %q/%q", preview.URLHash, preview.URL, params.URLHash, params.URL)
|
||||||
|
}
|
||||||
|
if preview.Title != params.Title || preview.Description != params.Description {
|
||||||
|
t.Errorf("Title/Description = %q/%q", preview.Title, preview.Description)
|
||||||
|
}
|
||||||
|
if preview.SiteName != params.SiteName || preview.ImageURL != params.ImageURL {
|
||||||
|
t.Errorf("SiteName/ImageURL = %q/%q", preview.SiteName, preview.ImageURL)
|
||||||
|
}
|
||||||
|
if preview.Status != LinkPreviewStatusOK {
|
||||||
|
t.Errorf("Status = %q, want %q", preview.Status, LinkPreviewStatusOK)
|
||||||
|
}
|
||||||
|
if want := st.Timestamp(params.FetchedAt); preview.FetchedAt != want {
|
||||||
|
t.Errorf("FetchedAt = %q, want %q", preview.FetchedAt, want)
|
||||||
|
}
|
||||||
|
if want := st.Timestamp(params.ExpiresAt); preview.ExpiresAt != want {
|
||||||
|
t.Errorf("ExpiresAt = %q, want %q", preview.ExpiresAt, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkPreviewUpsert(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st := newLinkPreviewTestStore(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
first := SaveLinkPreviewParams{
|
||||||
|
URLHash: "hash-upsert",
|
||||||
|
URL: "https://example.com/page",
|
||||||
|
Title: "Старый заголовок",
|
||||||
|
Status: LinkPreviewStatusEmpty,
|
||||||
|
FetchedAt: now,
|
||||||
|
ExpiresAt: now.Add(time.Hour),
|
||||||
|
}
|
||||||
|
if err := st.SaveLinkPreview(ctx, first); err != nil {
|
||||||
|
t.Fatalf("SaveLinkPreview() вернул ошибку: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
second := SaveLinkPreviewParams{
|
||||||
|
URLHash: first.URLHash,
|
||||||
|
URL: first.URL,
|
||||||
|
Title: "Новый заголовок",
|
||||||
|
Description: "Новое описание",
|
||||||
|
ImageURL: "https://example.com/new.png",
|
||||||
|
Status: LinkPreviewStatusOK,
|
||||||
|
FetchedAt: now.Add(time.Minute),
|
||||||
|
ExpiresAt: now.Add(2 * time.Hour),
|
||||||
|
}
|
||||||
|
if err := st.SaveLinkPreview(ctx, second); err != nil {
|
||||||
|
t.Fatalf("SaveLinkPreview() при перезаписи вернул ошибку: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
preview, err := st.GetLinkPreview(ctx, first.URLHash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLinkPreview() вернул ошибку: %v", err)
|
||||||
|
}
|
||||||
|
if preview.Title != second.Title || preview.Description != second.Description {
|
||||||
|
t.Errorf("после upsert Title/Description = %q/%q, want %q/%q",
|
||||||
|
preview.Title, preview.Description, second.Title, second.Description)
|
||||||
|
}
|
||||||
|
if preview.Status != LinkPreviewStatusOK {
|
||||||
|
t.Errorf("после upsert Status = %q, want %q", preview.Status, LinkPreviewStatusOK)
|
||||||
|
}
|
||||||
|
if want := st.Timestamp(second.ExpiresAt); preview.ExpiresAt != want {
|
||||||
|
t.Errorf("после upsert ExpiresAt = %q, want %q", preview.ExpiresAt, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int
|
||||||
|
if err := st.Reader().QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM link_previews WHERE url_hash = ?`, first.URLHash).Scan(&count); err != nil {
|
||||||
|
t.Fatalf("подсчёт строк: %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("строк с тем же url_hash = %d, want 1", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkPreviewExpiredNotFound(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st := newLinkPreviewTestStore(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
params := SaveLinkPreviewParams{
|
||||||
|
URLHash: "hash-expired",
|
||||||
|
URL: "https://example.com/old",
|
||||||
|
Title: "Просроченное",
|
||||||
|
Status: LinkPreviewStatusOK,
|
||||||
|
FetchedAt: now.Add(-2 * time.Hour),
|
||||||
|
ExpiresAt: now.Add(-time.Hour),
|
||||||
|
}
|
||||||
|
if err := st.SaveLinkPreview(ctx, params); err != nil {
|
||||||
|
t.Fatalf("SaveLinkPreview() вернул ошибку: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := st.GetLinkPreview(ctx, params.URLHash); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("GetLinkPreview() err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteExpiredLinkPreviews(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st := newLinkPreviewTestStore(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
fresh := SaveLinkPreviewParams{
|
||||||
|
URLHash: "hash-fresh",
|
||||||
|
URL: "https://example.com/fresh",
|
||||||
|
Title: "Свежее",
|
||||||
|
Status: LinkPreviewStatusOK,
|
||||||
|
FetchedAt: now,
|
||||||
|
ExpiresAt: now.Add(time.Hour),
|
||||||
|
}
|
||||||
|
expired := SaveLinkPreviewParams{
|
||||||
|
URLHash: "hash-old",
|
||||||
|
URL: "https://example.com/old",
|
||||||
|
Title: "Просроченное",
|
||||||
|
Status: LinkPreviewStatusError,
|
||||||
|
FetchedAt: now.Add(-2 * time.Hour),
|
||||||
|
ExpiresAt: now.Add(-time.Hour),
|
||||||
|
}
|
||||||
|
for _, params := range []SaveLinkPreviewParams{fresh, expired} {
|
||||||
|
if err := st.SaveLinkPreview(ctx, params); err != nil {
|
||||||
|
t.Fatalf("SaveLinkPreview(%q) вернул ошибку: %v", params.URLHash, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted, err := st.DeleteExpiredLinkPreviews(ctx, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeleteExpiredLinkPreviews() вернул ошибку: %v", err)
|
||||||
|
}
|
||||||
|
if deleted != 1 {
|
||||||
|
t.Errorf("удалено записей = %d, want 1", deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
var left int
|
||||||
|
if err := st.Reader().QueryRowContext(ctx, `SELECT COUNT(*) FROM link_previews`).Scan(&left); err != nil {
|
||||||
|
t.Fatalf("подсчёт строк: %v", err)
|
||||||
|
}
|
||||||
|
if left != 1 {
|
||||||
|
t.Errorf("в таблице осталось %d записей, want 1", left)
|
||||||
|
}
|
||||||
|
if _, err := st.GetLinkPreview(ctx, fresh.URLHash); err != nil {
|
||||||
|
t.Errorf("свежая запись недоступна: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
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 ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
// 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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,664 @@
|
|||||||
|
package unfurl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"golang.org/x/net/dns/dnsmessage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testFetcher собирает загрузчик для httptest-серверов: они живут на петле,
|
||||||
|
// поэтому без AllowPrivate адрес 127.0.0.1 был бы запрещён.
|
||||||
|
func testFetcher(t *testing.T, mutate func(*Options)) *Fetcher {
|
||||||
|
t.Helper()
|
||||||
|
options := Options{Timeout: 2 * time.Second, AllowPrivate: true}
|
||||||
|
if mutate != nil {
|
||||||
|
mutate(&options)
|
||||||
|
}
|
||||||
|
return NewFetcher(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveHTML поднимает сервер, отдающий заданный Content-Type и тело.
|
||||||
|
func serveHTML(t *testing.T, contentType string, body string) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
if contentType != "" {
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, body)
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalize(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
want string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"регистр хоста и фрагмент", "https://Example.COM/Path?q=1#frag", "https://example.com/Path?q=1", true},
|
||||||
|
{"пустой путь", "http://example.com", "http://example.com/", true},
|
||||||
|
{"порт сохраняется", "http://example.com:8080/a", "http://example.com:8080/a", true},
|
||||||
|
{"пробелы по краям", " https://example.com/a ", "https://example.com/a", true},
|
||||||
|
{"ipv6-литерал", "http://[::1]:8080/x", "http://[::1]:8080/x", true},
|
||||||
|
{"file", "file:///etc/passwd", "", false},
|
||||||
|
{"javascript", "javascript:alert(1)", "", false},
|
||||||
|
{"data", "data:text/html,<h1>x</h1>", "", false},
|
||||||
|
{"ftp", "ftp://example.com/x", "", false},
|
||||||
|
{"gopher", "gopher://example.com/", "", false},
|
||||||
|
{"userinfo", "http://user:pass@example.com/", "", false},
|
||||||
|
{"пустой хост", "http://", "", false},
|
||||||
|
{"пустая строка", "", "", false},
|
||||||
|
{"относительная ссылка", "/only/path", "", false},
|
||||||
|
}
|
||||||
|
for _, testCase := range cases {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
got, ok := Normalize(testCase.raw)
|
||||||
|
if ok != testCase.ok {
|
||||||
|
t.Fatalf("Normalize(%q) ok = %v, want %v", testCase.raw, ok, testCase.ok)
|
||||||
|
}
|
||||||
|
if got != testCase.want {
|
||||||
|
t.Errorf("Normalize(%q) = %q, want %q", testCase.raw, got, testCase.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestURLHash(t *testing.T) {
|
||||||
|
first := URLHash("https://example.com/a")
|
||||||
|
second := URLHash("https://example.com/a")
|
||||||
|
other := URLHash("https://example.com/b")
|
||||||
|
|
||||||
|
if len(first) != 64 {
|
||||||
|
t.Errorf("длина хэша = %d, want 64", len(first))
|
||||||
|
}
|
||||||
|
if strings.Trim(first, "0123456789abcdef") != "" {
|
||||||
|
t.Errorf("хэш %q не является hex-строкой", first)
|
||||||
|
}
|
||||||
|
if first != second {
|
||||||
|
t.Errorf("хэш не детерминирован: %q != %q", first, second)
|
||||||
|
}
|
||||||
|
if first == other {
|
||||||
|
t.Errorf("разные URL дали одинаковый хэш %q", first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchOpenGraph(t *testing.T) {
|
||||||
|
const page = `<!doctype html>
|
||||||
|
<html><head>
|
||||||
|
<meta property="og:title" content="Заголовок & сущность">
|
||||||
|
<meta property="OG:DESCRIPTION" content="Описание страницы">
|
||||||
|
<meta property="og:site_name" content="Пример">
|
||||||
|
<meta property="og:image" content="/img/pic.png">
|
||||||
|
</head><body>тело</body></html>`
|
||||||
|
|
||||||
|
var sawRequest atomic.Bool
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("User-Agent"); !strings.HasPrefix(got, defaultUserAgent) {
|
||||||
|
t.Errorf("User-Agent = %q, want префикс %q", got, defaultUserAgent)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Accept"); got != acceptHTML {
|
||||||
|
t.Errorf("Accept = %q, want %q", got, acceptHTML)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Cookie"); got != "" {
|
||||||
|
t.Errorf("запрос ушёл с Cookie: %q", got)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Authorization"); got != "" {
|
||||||
|
t.Errorf("запрос ушёл с Authorization: %q", got)
|
||||||
|
}
|
||||||
|
sawRequest.Store(true)
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = io.WriteString(w, page)
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
preview, err := testFetcher(t, nil).Fetch(context.Background(), server.URL+"/page")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fetch() вернул ошибку: %v", err)
|
||||||
|
}
|
||||||
|
if !sawRequest.Load() {
|
||||||
|
t.Fatal("сервер не получил запрос")
|
||||||
|
}
|
||||||
|
if preview.URL != server.URL+"/page" {
|
||||||
|
t.Errorf("URL = %q, want %q", preview.URL, server.URL+"/page")
|
||||||
|
}
|
||||||
|
if preview.Title != "Заголовок & сущность" {
|
||||||
|
t.Errorf("Title = %q", preview.Title)
|
||||||
|
}
|
||||||
|
if preview.Description != "Описание страницы" {
|
||||||
|
t.Errorf("Description = %q", preview.Description)
|
||||||
|
}
|
||||||
|
if preview.SiteName != "Пример" {
|
||||||
|
t.Errorf("SiteName = %q", preview.SiteName)
|
||||||
|
}
|
||||||
|
if want := server.URL + "/img/pic.png"; preview.ImageURL != want {
|
||||||
|
t.Errorf("ImageURL = %q, want %q", preview.ImageURL, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchFallbackToTitleAndDescription(t *testing.T) {
|
||||||
|
const page = `<html><head>
|
||||||
|
<title>
|
||||||
|
Заголовок
|
||||||
|
в несколько строк
|
||||||
|
</title>
|
||||||
|
<meta name="description" content=" Описание
|
||||||
|
страницы ">
|
||||||
|
</head><body></body></html>`
|
||||||
|
|
||||||
|
server := serveHTML(t, "text/html", page)
|
||||||
|
preview, err := testFetcher(t, nil).Fetch(context.Background(), server.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fetch() вернул ошибку: %v", err)
|
||||||
|
}
|
||||||
|
if preview.Title != "Заголовок в несколько строк" {
|
||||||
|
t.Errorf("Title = %q", preview.Title)
|
||||||
|
}
|
||||||
|
if preview.Description != "Описание страницы" {
|
||||||
|
t.Errorf("Description = %q", preview.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchTwitterFallback(t *testing.T) {
|
||||||
|
const page = `<html><head>
|
||||||
|
<meta name="twitter:title" content="Твиттер-заголовок">
|
||||||
|
<meta name="twitter:description" content="Твиттер-описание">
|
||||||
|
<meta name="twitter:image" content="img/tw.png">
|
||||||
|
</head></html>`
|
||||||
|
|
||||||
|
server := serveHTML(t, "text/html", page)
|
||||||
|
preview, err := testFetcher(t, nil).Fetch(context.Background(), server.URL+"/dir/page")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fetch() вернул ошибку: %v", err)
|
||||||
|
}
|
||||||
|
if preview.Title != "Твиттер-заголовок" {
|
||||||
|
t.Errorf("Title = %q", preview.Title)
|
||||||
|
}
|
||||||
|
if preview.Description != "Твиттер-описание" {
|
||||||
|
t.Errorf("Description = %q", preview.Description)
|
||||||
|
}
|
||||||
|
// Относительная картинка разрешается от каталога страницы.
|
||||||
|
if want := server.URL + "/dir/img/tw.png"; preview.ImageURL != want {
|
||||||
|
t.Errorf("ImageURL = %q, want %q", preview.ImageURL, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchRejectsNonHTML(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
contentType string
|
||||||
|
}{
|
||||||
|
{"png", "image/png"},
|
||||||
|
{"pdf", "application/pdf"},
|
||||||
|
{"json", "application/json; charset=utf-8"},
|
||||||
|
{"пустой", ""},
|
||||||
|
}
|
||||||
|
for _, testCase := range cases {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
// Пустое значение всё равно выставляем в карту заголовков:
|
||||||
|
// так сервер не подставляет Content-Type по содержимому.
|
||||||
|
w.Header()["Content-Type"] = []string{testCase.contentType}
|
||||||
|
_, _ = io.WriteString(w, `<html><head><title>нет</title></head></html>`)
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
_, err := testFetcher(t, nil).Fetch(context.Background(), server.URL)
|
||||||
|
if !errors.Is(err, ErrContentType) {
|
||||||
|
t.Fatalf("err = %v, want ErrContentType", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchRejectsTooLarge(t *testing.T) {
|
||||||
|
body := `<html><head><title>большая страница</title></head><body>` +
|
||||||
|
strings.Repeat("x", 4096) + `</body></html>`
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
// Content-Length задаём явно: по нему размер проверяется до чтения тела.
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
||||||
|
_, _ = io.WriteString(w, body)
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
fetcher := testFetcher(t, func(options *Options) { options.MaxBytes = 64 })
|
||||||
|
_, err := fetcher.Fetch(context.Background(), server.URL)
|
||||||
|
if !errors.Is(err, ErrTooLarge) {
|
||||||
|
t.Fatalf("err = %v, want ErrTooLarge", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchTruncatedBodyKeepsMetadata: без Content-Length тело читается до
|
||||||
|
// MaxBytes, и уже разобранные метаданные не теряются.
|
||||||
|
func TestFetchTruncatedBodyKeepsMetadata(t *testing.T) {
|
||||||
|
head := `<html><head><title>Короткий заголовок</title></head><body>`
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
_, _ = io.WriteString(w, head)
|
||||||
|
w.(http.Flusher).Flush() // без Content-Length: ответ пойдёт chunked
|
||||||
|
_, _ = io.WriteString(w, strings.Repeat("x", 8192))
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
fetcher := testFetcher(t, func(options *Options) { options.MaxBytes = 96 })
|
||||||
|
preview, err := fetcher.Fetch(context.Background(), server.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fetch() вернул ошибку: %v", err)
|
||||||
|
}
|
||||||
|
if preview.Title != "Короткий заголовок" {
|
||||||
|
t.Errorf("Title = %q, want %q", preview.Title, "Короткий заголовок")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchStopsAtHead(t *testing.T) {
|
||||||
|
const page = `<html><head></head><body><title>Из тела</title>
|
||||||
|
<meta property="og:title" content="Тоже из тела"></body></html>`
|
||||||
|
server := serveHTML(t, "text/html", page)
|
||||||
|
|
||||||
|
preview, err := testFetcher(t, nil).Fetch(context.Background(), server.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fetch() вернул ошибку: %v", err)
|
||||||
|
}
|
||||||
|
if !preview.Empty() {
|
||||||
|
t.Errorf("превью = %+v, want пустое: тело страницы не разбираем", preview)
|
||||||
|
}
|
||||||
|
if preview.URL != server.URL+"/" {
|
||||||
|
t.Errorf("URL = %q, want %q", preview.URL, server.URL+"/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchBlocksInternalAddresses: запросы на внутренние адреса отбиваются до
|
||||||
|
// соединения, серверы поднимать не нужно.
|
||||||
|
func TestFetchBlocksInternalAddresses(t *testing.T) {
|
||||||
|
urls := []string{
|
||||||
|
"http://127.0.0.1:1/",
|
||||||
|
"http://127.255.255.254/",
|
||||||
|
"http://169.254.169.254/latest/meta-data/",
|
||||||
|
"http://10.1.2.3/",
|
||||||
|
"http://192.168.1.1/",
|
||||||
|
"http://172.16.0.1/",
|
||||||
|
"http://172.31.255.254/",
|
||||||
|
"http://0.0.0.0/",
|
||||||
|
"http://100.64.0.1/",
|
||||||
|
"http://[::1]:8080/",
|
||||||
|
"http://[::ffff:10.0.0.1]/",
|
||||||
|
"http://[fe80::1]/",
|
||||||
|
"http://[::]/",
|
||||||
|
}
|
||||||
|
|
||||||
|
fetcher := NewFetcher(Options{Timeout: time.Second})
|
||||||
|
start := time.Now()
|
||||||
|
for _, raw := range urls {
|
||||||
|
t.Run(raw, func(t *testing.T) {
|
||||||
|
_, err := fetcher.Fetch(context.Background(), raw)
|
||||||
|
if !errors.Is(err, ErrBlocked) {
|
||||||
|
t.Fatalf("Fetch(%q) err = %v, want ErrBlocked", raw, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Проверка идёт до соединения: весь набор укладывается в доли секунды.
|
||||||
|
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
||||||
|
t.Errorf("проверка адресов заняла %v — похоже, запросы всё-таки уходили в сеть", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchAllowPrivateDisabledBlocksLoopback(t *testing.T) {
|
||||||
|
server := serveHTML(t, "text/html", `<html><head><title>привет</title></head></html>`)
|
||||||
|
|
||||||
|
fetcher := NewFetcher(Options{Timeout: time.Second})
|
||||||
|
_, err := fetcher.Fetch(context.Background(), server.URL)
|
||||||
|
if !errors.Is(err, ErrBlocked) {
|
||||||
|
t.Fatalf("err = %v, want ErrBlocked", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchRejectsUnsupportedURL(t *testing.T) {
|
||||||
|
fetcher := NewFetcher(Options{Timeout: time.Second})
|
||||||
|
for _, raw := range []string{"file:///etc/passwd", "javascript:alert(1)", "http://user:pass@example.com/"} {
|
||||||
|
if _, err := fetcher.Fetch(context.Background(), raw); !errors.Is(err, ErrUnsupported) {
|
||||||
|
t.Errorf("Fetch(%q) err = %v, want ErrUnsupported", raw, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchRedirectToBlockedAddress(t *testing.T) {
|
||||||
|
// Петля в тестовом режиме разрешена (на ней стоят httptest-серверы),
|
||||||
|
// поэтому цель перехода — заведомо запрещённый частный адрес.
|
||||||
|
const blocked = "http://10.1.2.3:1/"
|
||||||
|
|
||||||
|
var mux http.ServeMux
|
||||||
|
mux.HandleFunc("/one", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/two", http.StatusFound)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/two", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, blocked, http.StatusFound)
|
||||||
|
})
|
||||||
|
server := httptest.NewServer(&mux)
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
_, err := testFetcher(t, nil).Fetch(context.Background(), server.URL+"/one")
|
||||||
|
if !errors.Is(err, ErrBlocked) {
|
||||||
|
t.Fatalf("err = %v, want ErrBlocked", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchRedirectToUnsupportedScheme(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "file:///etc/passwd", http.StatusFound)
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
_, err := testFetcher(t, nil).Fetch(context.Background(), server.URL)
|
||||||
|
if !errors.Is(err, ErrUnsupported) {
|
||||||
|
t.Fatalf("err = %v, want ErrUnsupported", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchRedirectLimit(t *testing.T) {
|
||||||
|
var requests atomic.Int64
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requests.Add(1)
|
||||||
|
http.Redirect(w, r, "/next", http.StatusFound)
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
_, err := testFetcher(t, nil).Fetch(context.Background(), server.URL)
|
||||||
|
if !errors.Is(err, errTooManyRedirects) {
|
||||||
|
t.Fatalf("err = %v, want errTooManyRedirects", err)
|
||||||
|
}
|
||||||
|
if got := requests.Load(); got != maxRedirects+1 {
|
||||||
|
t.Errorf("запросов = %d, want %d (исходный + %d перехода)", got, maxRedirects+1, maxRedirects)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchFinalURLIsBaseForRelativeLinks: относительная картинка считается от
|
||||||
|
// адреса после редиректов, а не от исходной ссылки.
|
||||||
|
func TestFetchFinalURLIsBaseForRelativeLinks(t *testing.T) {
|
||||||
|
var mux http.ServeMux
|
||||||
|
mux.HandleFunc("/start", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/dir/final", http.StatusFound)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/dir/final", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
_, _ = io.WriteString(w, `<html><head><meta property="og:image" content="pic.png"></head></html>`)
|
||||||
|
})
|
||||||
|
server := httptest.NewServer(&mux)
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
preview, err := testFetcher(t, nil).Fetch(context.Background(), server.URL+"/start")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fetch() вернул ошибку: %v", err)
|
||||||
|
}
|
||||||
|
if want := server.URL + "/dir/pic.png"; preview.ImageURL != want {
|
||||||
|
t.Errorf("ImageURL = %q, want %q", preview.ImageURL, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchTimeout(t *testing.T) {
|
||||||
|
const timeout = 250 * time.Millisecond
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
time.Sleep(800 * time.Millisecond) // дольше таймаута загрузчика
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
_, _ = io.WriteString(w, `<html><head><title>поздно</title></head></html>`)
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
fetcher := testFetcher(t, func(options *Options) { options.Timeout = timeout })
|
||||||
|
start := time.Now()
|
||||||
|
_, err := fetcher.Fetch(context.Background(), server.URL)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Fetch() вернул nil, want ошибку таймаута")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) && !os.IsTimeout(err) {
|
||||||
|
t.Errorf("err = %v, want таймаут", err)
|
||||||
|
}
|
||||||
|
if elapsed > 2*timeout {
|
||||||
|
t.Errorf("Fetch() занял %v, want не больше %v", elapsed, 2*timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchDropsBlockedImage(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
page string
|
||||||
|
}{
|
||||||
|
{"частный адрес", `<meta property="og:image" content="http://10.0.0.1/x.png">`},
|
||||||
|
{"метаданные облака", `<meta property="og:image" content="http://169.254.169.254/x.png">`},
|
||||||
|
{"javascript", `<meta property="og:image" content="javascript:alert(1)">`},
|
||||||
|
{"data", `<meta property="og:image" content="data:image/png;base64,AAAA">`},
|
||||||
|
{"localhost", `<meta property="og:image" content="http://localhost/x.png">`},
|
||||||
|
}
|
||||||
|
for _, testCase := range cases {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
page := `<html><head><meta property="og:title" content="Заголовок">` + testCase.page + `</head></html>`
|
||||||
|
server := serveHTML(t, "text/html", page)
|
||||||
|
|
||||||
|
preview, err := testFetcher(t, nil).Fetch(context.Background(), server.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fetch() вернул ошибку: %v", err)
|
||||||
|
}
|
||||||
|
if preview.ImageURL != "" {
|
||||||
|
t.Errorf("ImageURL = %q, want пусто", preview.ImageURL)
|
||||||
|
}
|
||||||
|
if preview.Title != "Заголовок" {
|
||||||
|
t.Errorf("Title = %q, want %q", preview.Title, "Заголовок")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchSanitizesText(t *testing.T) {
|
||||||
|
const page = "<html><head>" +
|
||||||
|
"<meta property=\"og:title\" content=\"Заголовок\u0001 с \u0007управляющими\n\n\tсимволами и пробелами\">" +
|
||||||
|
"<meta property=\"og:description\" content=\" описание\nс переводом \">" +
|
||||||
|
"<meta property=\"og:site_name\" content=\" сайт \">" +
|
||||||
|
"</head></html>"
|
||||||
|
server := serveHTML(t, "text/html", page)
|
||||||
|
|
||||||
|
preview, err := testFetcher(t, nil).Fetch(context.Background(), server.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fetch() вернул ошибку: %v", err)
|
||||||
|
}
|
||||||
|
if want := "Заголовок с управляющими символами и пробелами"; preview.Title != want {
|
||||||
|
t.Errorf("Title = %q, want %q", preview.Title, want)
|
||||||
|
}
|
||||||
|
if want := "описание с переводом"; preview.Description != want {
|
||||||
|
t.Errorf("Description = %q, want %q", preview.Description, want)
|
||||||
|
}
|
||||||
|
if want := "сайт"; preview.SiteName != want {
|
||||||
|
t.Errorf("SiteName = %q, want %q", preview.SiteName, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchTruncatesLongText(t *testing.T) {
|
||||||
|
page := `<html><head>` +
|
||||||
|
`<meta property="og:title" content="` + strings.Repeat("я", 500) + `">` +
|
||||||
|
`<meta property="og:description" content="` + strings.Repeat("d", 900) + `">` +
|
||||||
|
`<meta property="og:site_name" content="` + strings.Repeat("s", 300) + `">` +
|
||||||
|
`</head></html>`
|
||||||
|
server := serveHTML(t, "text/html", page)
|
||||||
|
|
||||||
|
preview, err := testFetcher(t, nil).Fetch(context.Background(), server.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fetch() вернул ошибку: %v", err)
|
||||||
|
}
|
||||||
|
if got := utf8.RuneCountInString(preview.Title); got != titleLimit {
|
||||||
|
t.Errorf("длина Title = %d рун, want %d", got, titleLimit)
|
||||||
|
}
|
||||||
|
if got := utf8.RuneCountInString(preview.Description); got != descriptionLimit {
|
||||||
|
t.Errorf("длина Description = %d рун, want %d", got, descriptionLimit)
|
||||||
|
}
|
||||||
|
if got := utf8.RuneCountInString(preview.SiteName); got != siteNameLimit {
|
||||||
|
t.Errorf("длина SiteName = %d рун, want %d", got, siteNameLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchPageWithoutMetadata(t *testing.T) {
|
||||||
|
server := serveHTML(t, "text/html", `<html><head><meta charset="utf-8"></head><body>текст</body></html>`)
|
||||||
|
|
||||||
|
preview, err := testFetcher(t, nil).Fetch(context.Background(), server.URL+"/page")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fetch() вернул ошибку: %v", err)
|
||||||
|
}
|
||||||
|
if !preview.Empty() {
|
||||||
|
t.Errorf("превью = %+v, want пустое", preview)
|
||||||
|
}
|
||||||
|
if want := server.URL + "/page"; preview.URL != want {
|
||||||
|
t.Errorf("URL = %q, want %q", preview.URL, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchHidesQueryInErrorsAndLogs: полный URL с query не попадает ни в
|
||||||
|
// ошибку, ни в лог — только хост и код причины (приватность).
|
||||||
|
func TestFetchHidesQueryInErrorsAndLogs(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
rawURL string
|
||||||
|
}{
|
||||||
|
{"запрещённый литерал", "http://127.0.0.1:1/page?token=secret"},
|
||||||
|
{"ошибка соединения", "http://rebind.invalid/page?token=secret"},
|
||||||
|
}
|
||||||
|
for _, testCase := range cases {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
var logged bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewTextHandler(&logged, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
fetcher := NewFetcher(Options{
|
||||||
|
Timeout: time.Second,
|
||||||
|
Logger: logger,
|
||||||
|
Resolver: fakeResolver(t, netip.MustParseAddr("10.1.2.3")),
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := fetcher.Fetch(context.Background(), testCase.rawURL)
|
||||||
|
if !errors.Is(err, ErrBlocked) {
|
||||||
|
t.Fatalf("err = %v, want ErrBlocked", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "secret") {
|
||||||
|
t.Errorf("в ошибке остался query: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(logged.String(), "secret") {
|
||||||
|
t.Errorf("в логе остался query: %s", logged.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(logged.String(), "host=") || !strings.Contains(logged.String(), "blocked") {
|
||||||
|
t.Errorf("лог не содержит хост и код причины: %s", logged.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchBlocksDNSRebinding: имя резолвится в частный адрес, но соединение
|
||||||
|
// не открывается — адрес проверяется в момент подключения. Локальный
|
||||||
|
// DNS-сервер отдаёт 10.1.2.3, наружу тест не ходит.
|
||||||
|
func TestFetchBlocksDNSRebinding(t *testing.T) {
|
||||||
|
resolver := fakeResolver(t, netip.MustParseAddr("10.1.2.3"))
|
||||||
|
fetcher := NewFetcher(Options{Timeout: time.Second, Resolver: resolver})
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, err := fetcher.Fetch(context.Background(), "http://rebind.invalid/page")
|
||||||
|
if !errors.Is(err, ErrBlocked) {
|
||||||
|
t.Fatalf("err = %v, want ErrBlocked", err)
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(start); elapsed > time.Second {
|
||||||
|
t.Errorf("проверка заняла %v — похоже, соединение всё-таки открывалось", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeResolver поднимает минимальный DNS-сервер на петле и отвечает указанным
|
||||||
|
// адресом на любой A-запрос. Нужен, чтобы проверить защиту от DNS rebinding
|
||||||
|
// без выхода во внешнюю сеть.
|
||||||
|
func fakeResolver(t *testing.T, answer netip.Addr) *net.Resolver {
|
||||||
|
t.Helper()
|
||||||
|
var listenConfig net.ListenConfig
|
||||||
|
conn, err := listenConfig.ListenPacket(context.Background(), "udp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("не удалось занять UDP-порт: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
buffer := make([]byte, 1500)
|
||||||
|
for {
|
||||||
|
n, addr, err := conn.ReadFrom(buffer)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if response := dnsAnswer(buffer[:n], answer); response != nil {
|
||||||
|
_, _ = conn.WriteTo(response, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return &net.Resolver{
|
||||||
|
PreferGo: true,
|
||||||
|
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||||
|
protocol := "udp"
|
||||||
|
if strings.HasPrefix(network, "tcp") {
|
||||||
|
protocol = "tcp"
|
||||||
|
}
|
||||||
|
var dialer net.Dialer
|
||||||
|
return dialer.DialContext(ctx, protocol, conn.LocalAddr().String())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dnsAnswer собирает ответ на запрос: A-запись с нужным адресом, для остальных
|
||||||
|
// типов — пустой список ответов (NODATA). Ошибки разбора означают, что пакет
|
||||||
|
// не наш — такой запрос просто игнорируется.
|
||||||
|
func dnsAnswer(query []byte, answer netip.Addr) []byte {
|
||||||
|
var parser dnsmessage.Parser
|
||||||
|
header, err := parser.Start(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
question, err := parser.Question()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
builder := dnsmessage.NewBuilder(nil, dnsmessage.Header{
|
||||||
|
ID: header.ID,
|
||||||
|
Response: true,
|
||||||
|
Authoritative: true,
|
||||||
|
RecursionAvailable: true,
|
||||||
|
})
|
||||||
|
builder.EnableCompression()
|
||||||
|
if err := builder.StartQuestions(); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := builder.Question(question); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := builder.StartAnswers(); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if question.Type == dnsmessage.TypeA && answer.Is4() {
|
||||||
|
err := builder.AResource(
|
||||||
|
dnsmessage.ResourceHeader{Name: question.Name, Class: dnsmessage.ClassINET, TTL: 60},
|
||||||
|
dnsmessage.AResource{A: answer.As4()})
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message, err := builder.Finish()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return message
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user