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() }