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:
@@ -63,6 +63,8 @@ func newPushTestServer(t *testing.T, withKeys bool) (*Server, *store.Store) {
|
||||
Argon2Parallelism: 1,
|
||||
LogLevel: "error",
|
||||
LogFormat: "json",
|
||||
UnfurlEnabled: true,
|
||||
UnfurlTimeout: 5 * time.Second,
|
||||
}
|
||||
if withKeys {
|
||||
cfg.VAPIDPrivateKey = testVAPIDPrivateKey(t)
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"glchat/internal/store"
|
||||
"glchat/internal/unfurl"
|
||||
)
|
||||
|
||||
// withUnfurl подменяет загрузчик превью на тестовый: httptest-серверы живут
|
||||
// на петле, поэтому AllowPrivate разрешён только здесь.
|
||||
func withUnfurl(t *testing.T, srv *Server, st *store.Store) {
|
||||
t.Helper()
|
||||
srv.unfurl = &linkPreviewer{
|
||||
enabled: true,
|
||||
fetcher: unfurl.NewFetcher(unfurl.Options{AllowPrivate: true}),
|
||||
store: st,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
}
|
||||
|
||||
// htmlServer отдаёт страницу с метаданными Open Graph.
|
||||
func htmlServer(t *testing.T, body string) *httptest.Server {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
return server
|
||||
}
|
||||
|
||||
func TestLinkPreviewReturnsCardAndCaches(t *testing.T) {
|
||||
srv, st := newPushTestServer(t, true)
|
||||
withUnfurl(t, srv, st)
|
||||
cookie := registerAndLogin(t, srv, "unfurl_user", "unfurl@example.com")
|
||||
|
||||
page := htmlServer(t, `<!doctype html><html><head>
|
||||
<meta property="og:title" content="Заголовок страницы">
|
||||
<meta property="og:description" content="Краткое описание">
|
||||
<meta property="og:site_name" content="Example">
|
||||
<meta property="og:image" content="/cover.png">
|
||||
</head><body>текст</body></html>`)
|
||||
|
||||
rec := doJSON(t, srv, http.MethodGet,
|
||||
"/api/v1/link-previews?url="+urlQueryEscape(page.URL+"/page?utm=1"), "", cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("превью = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
payload := decodeResponse[struct {
|
||||
Status string `json:"status"`
|
||||
Preview struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
SiteName string `json:"site_name"`
|
||||
ImageURL string `json:"image_url"`
|
||||
} `json:"preview"`
|
||||
}](t, rec)
|
||||
if payload.Status != store.LinkPreviewStatusOK {
|
||||
t.Fatalf("статус = %q, body = %s", payload.Status, rec.Body.String())
|
||||
}
|
||||
if payload.Preview.Title != "Заголовок страницы" || payload.Preview.Description != "Краткое описание" {
|
||||
t.Fatalf("неожиданная карточка: %+v", payload.Preview)
|
||||
}
|
||||
if payload.Preview.SiteName != "Example" {
|
||||
t.Fatalf("site_name = %q", payload.Preview.SiteName)
|
||||
}
|
||||
if !strings.HasPrefix(payload.Preview.ImageURL, page.URL) {
|
||||
t.Fatalf("относительная картинка не развёрнута: %q", payload.Preview.ImageURL)
|
||||
}
|
||||
|
||||
// Второй запрос обслуживается кэшем: страница уже недоступна.
|
||||
page.Close()
|
||||
rec = doJSON(t, srv, http.MethodGet,
|
||||
"/api/v1/link-previews?url="+urlQueryEscape(page.URL+"/page?utm=1"), "", cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("повторный запрос = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
cached := decodeResponse[struct {
|
||||
Status string `json:"status"`
|
||||
Preview struct {
|
||||
Title string `json:"title"`
|
||||
} `json:"preview"`
|
||||
}](t, rec)
|
||||
if cached.Status != store.LinkPreviewStatusOK || cached.Preview.Title != "Заголовок страницы" {
|
||||
t.Fatalf("кэш не отдал превью: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkPreviewEmptyPage(t *testing.T) {
|
||||
srv, st := newPushTestServer(t, true)
|
||||
withUnfurl(t, srv, st)
|
||||
cookie := registerAndLogin(t, srv, "unfurl_empty", "unfurl-empty@example.com")
|
||||
|
||||
page := htmlServer(t, `<!doctype html><html><head><title></title></head><body>нет метаданных</body></html>`)
|
||||
rec := doJSON(t, srv, http.MethodGet,
|
||||
"/api/v1/link-previews?url="+urlQueryEscape(page.URL), "", cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("ответ = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
payload := decodeResponse[struct {
|
||||
Status string `json:"status"`
|
||||
Preview any `json:"preview"`
|
||||
}](t, rec)
|
||||
// <title></title> пустой: карточки нет.
|
||||
if payload.Status != store.LinkPreviewStatusEmpty || payload.Preview != nil {
|
||||
t.Fatalf("ожидался empty без карточки: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkPreviewRejectsInternalAndUnsupported(t *testing.T) {
|
||||
srv, st := newPushTestServer(t, true)
|
||||
withUnfurl(t, srv, st)
|
||||
cookie := registerAndLogin(t, srv, "unfurl_bad", "unfurl-bad@example.com")
|
||||
|
||||
// Проверяем боевую конфигурацию: AllowPrivate в тестовом загрузчике
|
||||
// разрешает петлю, поэтому здесь собираем загрузчик как в продакшене.
|
||||
srv.unfurl.fetcher = unfurl.NewFetcher(unfurl.Options{})
|
||||
|
||||
cases := map[string]string{
|
||||
"метаданные облака": "https://169.254.169.254/latest/meta-data/",
|
||||
"частная сеть": "https://10.1.2.3/secret",
|
||||
"петля": "https://127.0.0.1:8080/api/v1/meta",
|
||||
"file": "file:///etc/passwd",
|
||||
"javascript": "javascript:alert(1)",
|
||||
"data": "data:text/html,<h1>x</h1>",
|
||||
}
|
||||
for name, target := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
rec := doJSON(t, srv, http.MethodGet,
|
||||
"/api/v1/link-previews?url="+urlQueryEscape(target), "", cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("ответ = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
payload := decodeResponse[struct {
|
||||
Status string `json:"status"`
|
||||
Preview any `json:"preview"`
|
||||
}](t, rec)
|
||||
if payload.Status != store.LinkPreviewStatusBlocked {
|
||||
t.Fatalf("статус = %q, ожидался blocked (%s)", payload.Status, rec.Body.String())
|
||||
}
|
||||
if payload.Preview != nil {
|
||||
t.Fatalf("карточка не должна отдаваться: %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkPreviewNonHTMLIsError(t *testing.T) {
|
||||
srv, st := newPushTestServer(t, true)
|
||||
withUnfurl(t, srv, st)
|
||||
cookie := registerAndLogin(t, srv, "unfurl_json", "unfurl-json@example.com")
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"title":"я не страница"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
rec := doJSON(t, srv, http.MethodGet,
|
||||
"/api/v1/link-previews?url="+urlQueryEscape(server.URL), "", cookie)
|
||||
payload := decodeResponse[struct {
|
||||
Status string `json:"status"`
|
||||
}](t, rec)
|
||||
if payload.Status != store.LinkPreviewStatusError {
|
||||
t.Fatalf("статус = %q, ожидался error (%s)", payload.Status, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkPreviewDisabled(t *testing.T) {
|
||||
srv, _ := newPushTestServer(t, true)
|
||||
srv.unfurl.enabled = false
|
||||
cookie := registerAndLogin(t, srv, "unfurl_off", "unfurl-off@example.com")
|
||||
|
||||
rec := doJSON(t, srv, http.MethodGet,
|
||||
"/api/v1/link-previews?url=https%3A%2F%2Fexample.com%2F", "", cookie)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("ответ = %d, ожидался 503", rec.Code)
|
||||
}
|
||||
if code := errorCodeOf(t, rec); code != "unfurl.disabled" {
|
||||
t.Fatalf("код ошибки = %q", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkPreviewRequiresSession(t *testing.T) {
|
||||
srv, _ := newPushTestServer(t, true)
|
||||
rec := doJSON(t, srv, http.MethodGet,
|
||||
"/api/v1/link-previews?url=https%3A%2F%2Fexample.com%2F", "")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("ответ = %d, ожидался 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Кэш не расходует лимит, а новые загрузки — расходуют: иначе один клиент
|
||||
// может гонять чужие страницы пачками.
|
||||
func TestLinkPreviewRateLimit(t *testing.T) {
|
||||
srv, st := newPushTestServer(t, true)
|
||||
withUnfurl(t, srv, st)
|
||||
cookie := registerAndLogin(t, srv, "unfurl_flood", "unfurl-flood@example.com")
|
||||
|
||||
page := htmlServer(t, `<html><head><meta property="og:title" content="t"></head></html>`)
|
||||
limited := false
|
||||
for i := 0; i < 40; i++ {
|
||||
target := page.URL + "/page-" + itoa(i)
|
||||
rec := doJSON(t, srv, http.MethodGet,
|
||||
"/api/v1/link-previews?url="+urlQueryEscape(target), "", cookie)
|
||||
if rec.Code == http.StatusTooManyRequests {
|
||||
limited = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !limited {
|
||||
t.Fatal("лимит новых загрузок превью не сработал")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaExposesUnfurlFlag(t *testing.T) {
|
||||
srv, _ := newPushTestServer(t, true)
|
||||
rec := doJSON(t, srv, http.MethodGet, "/api/v1/meta", "")
|
||||
payload := decodeResponse[struct {
|
||||
Features struct {
|
||||
UnfurlEnabled bool `json:"unfurl_enabled"`
|
||||
} `json:"features"`
|
||||
}](t, rec)
|
||||
if !payload.Features.UnfurlEnabled {
|
||||
t.Fatal("meta не сообщает о включённом unfurl")
|
||||
}
|
||||
}
|
||||
|
||||
// urlQueryEscape кодирует URL для query-параметра.
|
||||
func urlQueryEscape(value string) string { return url.QueryEscape(value) }
|
||||
|
||||
// itoa нужен для уникальных адресов страниц в тесте лимита.
|
||||
func itoa(value int) string { return strconv.Itoa(value) }
|
||||
@@ -78,6 +78,10 @@ type Server struct {
|
||||
pushNotifyLimiter *httpx.RateLimiter
|
||||
// push отправляет Web Push; nil, если ключи не настроены.
|
||||
push pushSender
|
||||
// unfurl загружает и кэширует превью ссылок (Фаза 7), unfurlLimiter
|
||||
// ограничивает новые загрузки (кэш лимит не расходует).
|
||||
unfurl *linkPreviewer
|
||||
unfurlLimiter *httpx.RateLimiter
|
||||
// slowmode — время последней отправки в комнату для режима медленной
|
||||
// отправки; словарь ограничен по размеру (AGENT.md 7.5).
|
||||
slowmodeMu sync.Mutex
|
||||
@@ -138,6 +142,9 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
||||
passkeyLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
||||
oauthLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
||||
pushLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
||||
// Новые превью ссылок: 20 в минуту на пользователя (кэшированные
|
||||
// ответы лимит не тратят).
|
||||
unfurlLimiter: httpx.NewRateLimiterWindow(20, time.Minute, 20),
|
||||
// Уведомления на получателя: 20 в минуту с запасом 20 — массовые
|
||||
// упоминания не должны заваливать устройство.
|
||||
pushNotifyLimiter: httpx.NewRateLimiter(20, 20),
|
||||
@@ -158,6 +165,11 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
||||
case deps.Store != nil:
|
||||
s.perms = permissions.NewCalculator(source.New(deps.Store))
|
||||
}
|
||||
// Превью ссылок (Фаза 7): загрузчик создаётся всегда, а выключенный
|
||||
// unfurl просто отвечает `unfurl.disabled` и в интернет не ходит.
|
||||
if deps.Store != nil {
|
||||
s.unfurl = newLinkPreviewer(cfg, deps.Store, logger)
|
||||
}
|
||||
// Web Push (Фаза 7): неверный VAPID-ключ не должен мешать инстансу
|
||||
// подняться — пишем предупреждение и работаем без push.
|
||||
if deps.Store != nil {
|
||||
@@ -204,6 +216,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
||||
s.registerInviteBackgroundRoutes(apiRouter)
|
||||
s.registerVoiceWebhook(apiRouter)
|
||||
s.registerPushRoutes(s.api)
|
||||
s.registerUnfurlRoutes(s.api)
|
||||
}
|
||||
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user