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