Files
glchat/internal/server/api_push.go
T

256 lines
8.9 KiB
Go
Raw Normal View History

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