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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user