219 lines
7.8 KiB
Go
219 lines
7.8 KiB
Go
|
|
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()
|
|||
|
|
}
|