From a9b107387704fe8ae146417d67d156a81647516c Mon Sep 17 00:00:00 2001 From: grendervill Date: Sat, 26 Sep 2026 16:15:00 +0300 Subject: [PATCH] =?UTF-8?q?feat(unfurl):=20=D0=BF=D1=80=D0=B5=D0=B2=D1=8C?= =?UTF-8?q?=D1=8E=20=D1=81=D1=81=D1=8B=D0=BB=D0=BE=D0=BA=20=D1=81=20=D0=B7?= =?UTF-8?q?=D0=B0=D1=89=D0=B8=D1=82=D0=BE=D0=B9=20=D0=BE=D1=82=20SSRF=20?= =?UTF-8?q?=D0=B8=20=D0=BA=D1=8D=D1=88=D0=B5=D0=BC=20=D0=B2=20=D0=91=D0=94?= =?UTF-8?q?=20(=D0=A4=D0=B0=D0=B7=D0=B0=207)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сервер сам загружает заголовок, описание и картинку страницы по ссылке из сообщения и отдаёт клиенту готовую карточку. Безопасность (главное здесь): - только 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 и миграция. --- .env.example | 5 + cmd/glchat/cli.go | 4 +- internal/config/config.go | 7 + .../migrations/00022_link_previews.sql | 22 + internal/meta/meta.go | 2 + internal/retention/retention.go | 14 +- internal/server/api_push_test.go | 2 + internal/server/api_unfurl.go | 218 ++++++ internal/server/api_unfurl_test.go | 241 +++++++ internal/server/server.go | 13 + internal/store/link_previews.go | 119 ++++ internal/store/link_previews_test.go | 200 ++++++ internal/unfurl/html.go | 179 +++++ internal/unfurl/unfurl.go | 482 +++++++++++++ internal/unfurl/unfurl_test.go | 664 ++++++++++++++++++ 15 files changed, 2168 insertions(+), 4 deletions(-) create mode 100644 internal/database/migrations/00022_link_previews.sql create mode 100644 internal/server/api_unfurl.go create mode 100644 internal/server/api_unfurl_test.go create mode 100644 internal/store/link_previews.go create mode 100644 internal/store/link_previews_test.go create mode 100644 internal/unfurl/html.go create mode 100644 internal/unfurl/unfurl.go create mode 100644 internal/unfurl/unfurl_test.go diff --git a/.env.example b/.env.example index 441e75c..a543109 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,11 @@ VAPID_PRIVATE_KEY= # Контакт администратора для push-сервисов (mailto:admin@<домен> или https://…). VAPID_SUBJECT= + +# --- превью ссылок (Фаза 7) --- +UNFURL_ENABLED=true # сервер сам загружает превью страниц по ссылкам +UNFURL_TIMEOUT_SECONDS=5 # таймаут загрузки превью + AGE_RECIPIENT= # публичный age-ключ для шифрования бэкапов BACKUP_KEEP_DAYS= # сколько дней хранить бэкапы (профиль задаёт значение) BACKUP_OFFSITE_TARGET= # необязательно: rsync-цель (user@host:/path) или rclone:remote:path diff --git a/cmd/glchat/cli.go b/cmd/glchat/cli.go index 3d7f9e2..16f58fd 100644 --- a/cmd/glchat/cli.go +++ b/cmd/glchat/cli.go @@ -403,9 +403,9 @@ func cliCleanup(args []string) int { } stats, err := retention.New(st, options).RunOnce(ctx) 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.PushSubscriptions, stats.Bytes) + stats.PushSubscriptions, stats.LinkPreviews, stats.Bytes) return err }) } diff --git a/internal/config/config.go b/internal/config/config.go index cc07b5a..8a1fa80 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -78,6 +78,10 @@ type Config struct { // (mailto:… или https://…). Пустой ключ — Web Push выключен. VAPIDPrivateKey string VAPIDSubject string + // Unfurl ссылок (AGENT.md Фаза 7): сервер сам загружает превью страниц по + // ссылкам из сообщений. Выключенный unfurl не ходит в интернет вовсе. + UnfurlEnabled bool + UnfurlTimeout time.Duration } func Load() (Config, error) { @@ -127,6 +131,9 @@ func Load() (Config, error) { VAPIDPrivateKey: env("VAPID_PRIVATE_KEY", ""), VAPIDSubject: env("VAPID_SUBJECT", ""), + + UnfurlEnabled: envBool("UNFURL_ENABLED", true), + UnfurlTimeout: time.Duration(envInt("UNFURL_TIMEOUT_SECONDS", 5)) * time.Second, } if cfg.FilesDomain == "" { cfg.FilesDomain = "files." + cfg.Domain diff --git a/internal/database/migrations/00022_link_previews.sql b/internal/database/migrations/00022_link_previews.sql new file mode 100644 index 0000000..55f7ca9 --- /dev/null +++ b/internal/database/migrations/00022_link_previews.sql @@ -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; diff --git a/internal/meta/meta.go b/internal/meta/meta.go index 372b443..ce52034 100644 --- a/internal/meta/meta.go +++ b/internal/meta/meta.go @@ -11,6 +11,7 @@ type Features struct { // VoiceURL — адрес LiveKit для клиента (ws/wss), пусто при выключенном голосе. VoiceURL string `json:"voice_url,omitempty"` WebPushEnabled bool `json:"web_push_enabled"` + UnfurlEnabled bool `json:"unfurl_enabled"` OAuthEnabled bool `json:"oauth_enabled"` PasskeysEnabled bool `json:"passkeys_enabled"` // OAuthProviders — включённые провайдеры входа (id): клиент рисует по @@ -50,6 +51,7 @@ func New(cfg config.Config) Response { VoiceEnabled: cfg.VoiceEnabled(), VoiceURL: cfg.LiveKitURL, WebPushEnabled: cfg.WebPushEnabled(), + UnfurlEnabled: cfg.UnfurlEnabled, OAuthEnabled: cfg.OAuthEnabled(), PasskeysEnabled: cfg.WebAuthnEnabled(), OAuthProviders: cfg.OAuthProviders(), diff --git a/internal/retention/retention.go b/internal/retention/retention.go index 5ea536e..1d10b45 100644 --- a/internal/retention/retention.go +++ b/internal/retention/retention.go @@ -42,10 +42,12 @@ type Stats struct { Bytes int64 // PushSubscriptions — мёртвые подписки Web Push (Фаза 7). PushSubscriptions int64 + // LinkPreviews — истёкшие записи кэша превью ссылок (Фаза 7). + LinkPreviews int64 } -// Мёртвые подписки Web Push (Фаза 7): подписка, которая не доставлялась -// неделю и накопила неудачи, больше не нужна. +// Мёртвые подписки Web Push и кэш превью ссылок (Фаза 7): подписка, которая +// не доставлялась неделю и накопила неудачи, и протухшее превью не нужны. const ( stalePushAfterDays = 7 stalePushMaxFailures = 5 @@ -136,6 +138,13 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) { } 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 if len(errs) > 0 { 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("unknown_files", stats.Unknown), slog.Int64("push_subscriptions", stats.PushSubscriptions), + slog.Int64("link_previews", stats.LinkPreviews), slog.Int64("bytes", stats.Bytes)) } } diff --git a/internal/server/api_push_test.go b/internal/server/api_push_test.go index df32eda..218eb5e 100644 --- a/internal/server/api_push_test.go +++ b/internal/server/api_push_test.go @@ -63,6 +63,8 @@ func newPushTestServer(t *testing.T, withKeys bool) (*Server, *store.Store) { Argon2Parallelism: 1, LogLevel: "error", LogFormat: "json", + UnfurlEnabled: true, + UnfurlTimeout: 5 * time.Second, } if withKeys { cfg.VAPIDPrivateKey = testVAPIDPrivateKey(t) diff --git a/internal/server/api_unfurl.go b/internal/server/api_unfurl.go new file mode 100644 index 0000000..269c62c --- /dev/null +++ b/internal/server/api_unfurl.go @@ -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() +} diff --git a/internal/server/api_unfurl_test.go b/internal/server/api_unfurl_test.go new file mode 100644 index 0000000..caae2ed --- /dev/null +++ b/internal/server/api_unfurl_test.go @@ -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, ` + + + + + текст`) + + 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, `нет метаданных`) + 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) + // пустой: карточки нет. + 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,

x

", + } + 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, ``) + 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) } diff --git a/internal/server/server.go b/internal/server/server.go index 2a47627..630d3de 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -78,6 +78,10 @@ type Server struct { pushNotifyLimiter *httpx.RateLimiter // push отправляет Web Push; nil, если ключи не настроены. push pushSender + // unfurl загружает и кэширует превью ссылок (Фаза 7), unfurlLimiter + // ограничивает новые загрузки (кэш лимит не расходует). + unfurl *linkPreviewer + unfurlLimiter *httpx.RateLimiter // slowmode — время последней отправки в комнату для режима медленной // отправки; словарь ограничен по размеру (AGENT.md 7.5). 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), oauthLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10), pushLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10), + // Новые превью ссылок: 20 в минуту на пользователя (кэшированные + // ответы лимит не тратят). + unfurlLimiter: httpx.NewRateLimiterWindow(20, time.Minute, 20), // Уведомления на получателя: 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: 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-ключ не должен мешать инстансу // подняться — пишем предупреждение и работаем без push. 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.registerVoiceWebhook(apiRouter) s.registerPushRoutes(s.api) + s.registerUnfurlRoutes(s.api) } apiRouter.Get("/openapi.json", s.handleOpenAPI) }) diff --git a/internal/store/link_previews.go b/internal/store/link_previews.go new file mode 100644 index 0000000..5ca0657 --- /dev/null +++ b/internal/store/link_previews.go @@ -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 +} diff --git a/internal/store/link_previews_test.go b/internal/store/link_previews_test.go new file mode 100644 index 0000000..263548b --- /dev/null +++ b/internal/store/link_previews_test.go @@ -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) + } +} diff --git a/internal/unfurl/html.go b/internal/unfurl/html.go new file mode 100644 index 0000000..9cfb6bb --- /dev/null +++ b/internal/unfurl/html.go @@ -0,0 +1,179 @@ +package unfurl + +import ( + "errors" + "fmt" + "io" + "net/netip" + "net/url" + "strings" + + "golang.org/x/net/html" +) + +// headMetadata — найденные в значения по источникам. Приоритет +// разбирается в 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 разбирает потоково и останавливается на : тело +// страницы не читается. Читатель обычно ограничен 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 раскладывает атрибуты по источникам. Ключи 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 "" +} diff --git a/internal/unfurl/unfurl.go b/internal/unfurl/unfurl.go new file mode 100644 index 0000000..7b2ef02 --- /dev/null +++ b/internal/unfurl/unfurl.go @@ -0,0 +1,482 @@ +// Package unfurl загружает серверные превью ссылок (unfurl, фаза 7 AGENT.md): +// заголовок, описание, имя сайта и картинку из страницы. +// +// Пакет намеренно недоверчив к внешнему миру: +// - только схемы 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 загружает страницу и разбирает метаданные её . Тело читается не +// целиком: разбор прекращается на или по достижении 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)) +} diff --git a/internal/unfurl/unfurl_test.go b/internal/unfurl/unfurl_test.go new file mode 100644 index 0000000..37ca0ac --- /dev/null +++ b/internal/unfurl/unfurl_test.go @@ -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,

x

", "", 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 = ` + + + + + +тело` + + 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 = ` + + Заголовок + в несколько строк + + +` + + 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 = ` + + + +` + + 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, `нет`) + })) + 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 := `большая страница` + + strings.Repeat("x", 4096) + `` + 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 := `Короткий заголовок` + 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 = `Из тела +` + 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", `привет`) + + 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, ``) + }) + 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, `поздно`) + })) + 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 + }{ + {"частный адрес", ``}, + {"метаданные облака", ``}, + {"javascript", ``}, + {"data", ``}, + {"localhost", ``}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + page := `` + testCase.page + `` + 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 = "" + + "" + + "" + + "" + + "" + 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 := `` + + `` + + `` + + `` + + `` + 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", `текст`) + + 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 +}