Files
glchat/internal/server/api_unfurl.go
T
grendervill a9b1073877 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 и миграция.
2026-09-26 16:15:00 +03:00

219 lines
7.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}