feat(unfurl): превью ссылок с защитой от SSRF и кэшем в БД (Фаза 7)

Сервер сам загружает заголовок, описание и картинку страницы по ссылке из
сообщения и отдаёт клиенту готовую карточку.

Безопасность (главное здесь):
- только http/https и без userinfo; запрет петли, частных сетей, link-local
  (169.254.169.254), CGNAT, multicast и IPv4-mapped вариантов;
- проверка идёт по адресу, к которому реально открывается TCP
  (`net.Dialer.Control`), поэтому подмена DNS между проверкой и соединением
  (DNS rebinding) ничего не даёт;
- не больше 3 редиректов, каждый хоп проверяется заново; таймаут 5 с, тело
  ≤ 512 КБ, только `text/html`; прокси из окружения игнорируются, cookie и
  авторизация не отправляются; в логи попадают только хост и код причины;
- картинка по ссылке не скачивается — проверяется лишь её URL: экономия CPU на
  1 vCPU и минус класс атак через декодирование.

Кэш: таблица `link_previews` (миграция 00022, ключ — sha256 нормализованного
URL), TTL по статусу (ok — сутки, empty/blocked — час, error — 10 минут).
Ручка `GET /api/v1/link-previews?url=…` отвечает статусом
(ok/empty/blocked/error) и карточкой только при ok; 20 новых загрузок в минуту
на пользователя, кэшированные ответы лимит не тратят. `UNFURL_ENABLED=false`
выключает функцию целиком, `features.unfurl_enabled` виден в `/meta`. Retention
убирает истёкшие записи кэша.

Тесты: 21 в `internal/unfurl` (включая DNS rebinding через локальный
DNS-сервер, редирект во внутреннюю сеть, таймаут, лимиты размера и типа),
ручки, store и миграция.
This commit is contained in:
2026-09-26 16:15:00 +03:00
parent 7afd23d6d4
commit a9b1073877
15 changed files with 2168 additions and 4 deletions
+119
View File
@@ -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
}
+200
View File
@@ -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)
}
}