package server import ( "context" "crypto/ecdsa" "crypto/elliptic" "encoding/base64" "net/http" "strconv" "strings" "github.com/danielgtaylor/huma/v2" "glchat/internal/httpx" "glchat/internal/store" ) // Web Push (AGENT.md 7.16, Фаза 7): подписка устройства, отписка и параметры // для клиента. VAPID-ключ приватный живёт в конфиге инстанса, наружу уходит // только публичный — он и нужен браузеру как `applicationServerKey`. // Лимиты подписок: устройств на пользователя и длины полей от клиента. const ( maxPushSubscriptionsPerUser = 10 maxPushEndpointLength = 1024 maxPushKeyLength = 128 maxPushUserAgentLength = 200 ) type pushConfigOutput struct { Body struct { Enabled bool `json:"enabled"` PublicKey string `json:"public_key,omitempty"` } } type pushSubscriptionPayload struct { ID string `json:"id"` Endpoint string `json:"endpoint"` UserAgent string `json:"user_agent,omitempty"` CreatedAt string `json:"created_at"` } type pushSubscriptionListOutput struct { Body struct { Subscriptions []pushSubscriptionPayload `json:"subscriptions"` // Limit — сколько устройств можно подписать (для интерфейса). Limit int `json:"limit"` } } type pushOKOutput struct { Body struct { OK bool `json:"ok"` } } // registerPushRoutes описывает ручки Web Push. func (s *Server) registerPushRoutes(api huma.API) { security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}} huma.Register(api, huma.Operation{ OperationID: "getPushConfig", Method: http.MethodGet, Path: "/push/config", Summary: "Параметры Web Push для клиента", Tags: []string{"Push"}, Security: security, }, func(ctx context.Context, _ *struct{}) (*pushConfigOutput, error) { if _, _, err := requireUser(ctx); err != nil { return nil, err } output := &pushConfigOutput{} output.Body.Enabled = s.push.Enabled() output.Body.PublicKey = s.push.PublicKey() return output, nil }) huma.Register(api, huma.Operation{ OperationID: "listPushSubscriptions", Method: http.MethodGet, Path: "/push/subscriptions", Summary: "Подписки устройств текущего пользователя", Tags: []string{"Push"}, Security: security, }, func(ctx context.Context, _ *struct{}) (*pushSubscriptionListOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } subscriptions, err := s.store.ListPushSubscriptions(ctx, user.ID) if err != nil { return nil, humaError(err) } output := &pushSubscriptionListOutput{} output.Body.Limit = maxPushSubscriptionsPerUser output.Body.Subscriptions = make([]pushSubscriptionPayload, 0, len(subscriptions)) for i := range subscriptions { output.Body.Subscriptions = append(output.Body.Subscriptions, pushSubscriptionPayload{ ID: formatSnowflake(subscriptions[i].ID), Endpoint: subscriptions[i].Endpoint, UserAgent: subscriptions[i].UserAgent, CreatedAt: s.store.Timestamp(subscriptions[i].CreatedAt), }) } return output, nil }) huma.Register(api, huma.Operation{ OperationID: "subscribePush", Method: http.MethodPost, Path: "/push/subscriptions", Summary: "Подписать устройство на Web Push", Tags: []string{"Push"}, Security: security, }, func(ctx context.Context, input *struct { UserAgent string `header:"User-Agent"` Body struct { Endpoint string `json:"endpoint" maxLength:"1024" minLength:"8"` Keys struct { P256dh string `json:"p256dh" maxLength:"128"` Auth string `json:"auth" maxLength:"128"` } `json:"keys"` } }, ) (*pushOKOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } if !s.push.Enabled() { return nil, humaErrorStatus(http.StatusServiceUnavailable, "push.disabled", "web push is not configured on this instance") } if allowed, retryAfter := s.pushLimiter.Allow(pushLimitKey(user.ID)); !allowed { return nil, rateLimitedError(retryAfter) } endpoint, err := validatePushEndpoint(input.Body.Endpoint) if err != nil { return nil, err } if err := validatePushKey("p256dh", input.Body.Keys.P256dh, 65); err != nil { return nil, err } if err := validatePushKey("auth", input.Body.Keys.Auth, 16); err != nil { return nil, err } count, err := s.store.CountPushSubscriptions(ctx, user.ID) if err != nil { return nil, humaError(err) } // Повторная подписка с того же устройства обновляет запись, поэтому // лимит проверяем только для новой. if count >= maxPushSubscriptionsPerUser { existing, err := s.store.GetPushSubscriptionByEndpoint(ctx, endpoint) if err != nil || existing.UserID != user.ID { return nil, humaErrorStatus(http.StatusConflict, "push.too_many_subscriptions", "too many subscribed devices") } } if _, err := s.store.SavePushSubscription(ctx, store.SavePushSubscriptionParams{ UserID: user.ID, Endpoint: endpoint, P256dh: input.Body.Keys.P256dh, Auth: input.Body.Keys.Auth, UserAgent: truncate(input.UserAgent, maxPushUserAgentLength), }); err != nil { return nil, humaError(err) } output := &pushOKOutput{} output.Body.OK = true return output, nil }) huma.Register(api, huma.Operation{ OperationID: "unsubscribePush", Method: http.MethodDelete, Path: "/push/subscriptions", Summary: "Отписать устройство (или все) от Web Push", Tags: []string{"Push"}, Security: security, }, func(ctx context.Context, input *struct { Endpoint string `query:"endpoint,omitempty" maxLength:"1024"` }, ) (*pushOKOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } endpoint := strings.TrimSpace(input.Endpoint) if endpoint != "" { if _, err := validatePushEndpoint(endpoint); err != nil { return nil, err } } if _, err := s.store.DeletePushSubscription(ctx, user.ID, endpoint); err != nil { return nil, humaError(err) } output := &pushOKOutput{} output.Body.OK = true return output, nil }) } // validatePushEndpoint проверяет эндпоинт подписки: http не допускаем, а // литеральный внутренний адрес отсекаем сразу — иначе сервер сам себе // организует SSRF, отправляя подписанный VAPID-запрос во внутреннюю сеть. func validatePushEndpoint(raw string) (string, error) { trimmed := strings.TrimSpace(raw) if trimmed == "" || len(trimmed) > maxPushEndpointLength { return "", humaErrorStatus(http.StatusUnprocessableEntity, "push.invalid_endpoint", "push endpoint is empty or too long") } if _, err := httpx.ValidatePublicURL(trimmed, false); err != nil { return "", humaErrorStatus(http.StatusUnprocessableEntity, "push.invalid_endpoint", "push endpoint must be a public https url") } return trimmed, nil } // validatePushKey проверяет ключ подписки: base64url, длину и — для p256dh — // что это действительно точка P-256. Без второй проверки мусор от клиента // доходил бы до шифрования и падал уже в очереди доставки. func validatePushKey(name, value string, wantBytes int) error { trimmed := strings.TrimSpace(value) if trimmed == "" || len(trimmed) > maxPushKeyLength { return humaErrorStatus(http.StatusUnprocessableEntity, "push.invalid_keys", name+" key is missing or too long") } decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(trimmed, "=")) if err != nil || len(decoded) != wantBytes { return humaErrorStatus(http.StatusUnprocessableEntity, "push.invalid_keys", name+" key must be base64url of "+strconv.Itoa(wantBytes)+" bytes") } if name == "p256dh" { if _, err := ecdsa.ParseUncompressedPublicKey(elliptic.P256(), decoded); err != nil { return humaErrorStatus(http.StatusUnprocessableEntity, "push.invalid_keys", "p256dh key is not a P-256 point") } } return nil } // pushLimitKey — ключ лимита подписок: 10 запросов в минуту на пользователя. func pushLimitKey(userID uint64) string { return "user:" + formatSnowflake(userID) } // truncate обрезает пользовательский текст до лимита (User-Agent устройства). func truncate(value string, limit int) string { trimmed := strings.TrimSpace(value) runes := []rune(trimmed) if len(runes) <= limit { return trimmed } return string(runes[:limit]) }