CI / ci (push) Successful in 8m31s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
127 lines
4.9 KiB
Go
127 lines
4.9 KiB
Go
package server
|
|
|
|
import (
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
|
|
)
|
|
|
|
// maxPushBody bounds a POST or DELETE /push/subscriptions body: an endpoint
|
|
// of push.MaxEndpointBytes, the keys and a user agent of
|
|
// push.MaxUserAgentBytes, with room for JSON escaping.
|
|
const maxPushBody = 8 << 10
|
|
|
|
type vapidPublicKeyResponse struct {
|
|
PublicKey string `json:"public_key"`
|
|
}
|
|
|
|
// pushSubscriptionBody is the POST /push/subscriptions body: the
|
|
// endpoint and keys of PushSubscription.toJSON(), without its
|
|
// expirationTime, and an optional user_agent.
|
|
type pushSubscriptionBody struct {
|
|
Endpoint string `json:"endpoint"`
|
|
Keys pushSubscriptionKeys `json:"keys"`
|
|
UserAgent *string `json:"user_agent"`
|
|
}
|
|
|
|
type pushSubscriptionKeys struct {
|
|
P256dh string `json:"p256dh"`
|
|
Auth string `json:"auth"`
|
|
}
|
|
|
|
// pushUnsubscribeBody is the DELETE /push/subscriptions body.
|
|
type pushUnsubscribeBody struct {
|
|
Endpoint string `json:"endpoint"`
|
|
}
|
|
|
|
// getVAPIDPublicKey answers GET /push/vapid-public-key with the key
|
|
// browsers pass as applicationServerKey. The private key is never served.
|
|
func getVAPIDPublicKey(logger *slog.Logger, svc *push.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, r, logger, http.StatusOK, vapidPublicKeyResponse{PublicKey: svc.PublicKey()})
|
|
}
|
|
}
|
|
|
|
// postPushSubscription stores the browser's subscription by its endpoint
|
|
// and answers 200 with it, without its keys. Posting an endpoint already
|
|
// stored updates it.
|
|
func postPushSubscription(logger *slog.Logger, svc *push.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
body, err := decodePatch[pushSubscriptionBody](http.MaxBytesReader(w, r.Body, maxPushBody))
|
|
if err != nil {
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
|
return
|
|
}
|
|
sub, err := svc.Subscribe(r.Context(), push.SubscribeRequest{
|
|
Endpoint: body.Endpoint,
|
|
P256dh: body.Keys.P256dh,
|
|
Auth: body.Keys.Auth,
|
|
UserAgent: body.UserAgent,
|
|
})
|
|
if err != nil {
|
|
writePushError(w, r, logger, "store push subscription", err)
|
|
return
|
|
}
|
|
writeJSON(w, r, logger, http.StatusOK, sub)
|
|
}
|
|
}
|
|
|
|
// deletePushSubscription removes the subscription for the body's endpoint
|
|
// and answers 204, whether or not it was stored.
|
|
func deletePushSubscription(logger *slog.Logger, svc *push.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
body, err := decodePatch[pushUnsubscribeBody](http.MaxBytesReader(w, r.Body, maxPushBody))
|
|
if err != nil {
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
|
return
|
|
}
|
|
if _, err := svc.Unsubscribe(r.Context(), body.Endpoint); err != nil {
|
|
writePushError(w, r, logger, "delete push subscription", err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// postTestPush answers POST /settings/notifications/test-push: it queues
|
|
// push.TestTitle for every device that turned on push, aimed at /plan, and
|
|
// answers 202 with the event key and how many subscriptions it goes to.
|
|
// Delivery follows through the notification outbox. With push switched
|
|
// off for plan_ready it is 409 push_off, whether or not any device has
|
|
// turned on push; otherwise with no subscription it is 409
|
|
// no_subscriptions. Neither sends anything. It never posts to Google Chat.
|
|
func postTestPush(logger *slog.Logger, svc *push.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
sent, err := svc.SendTest(r.Context())
|
|
switch {
|
|
case errors.Is(err, push.ErrNoSubscriptions):
|
|
writeError(w, r, logger, http.StatusConflict, "no_subscriptions",
|
|
"no device has turned on notifications, so there is nothing to send the test to")
|
|
case errors.Is(err, notify.ErrPushOff):
|
|
writeError(w, r, logger, http.StatusConflict, "push_off",
|
|
"push is switched off for New weekly plan ready in Notifications settings, so no test was sent")
|
|
case err != nil:
|
|
logger.ErrorContext(r.Context(), "send test push", "error", err)
|
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
|
default:
|
|
logger.InfoContext(r.Context(), "test push queued", "event_key", sent.EventKey, "subscriptions", sent.Subscriptions)
|
|
writeJSON(w, r, logger, http.StatusAccepted, sent)
|
|
}
|
|
}
|
|
}
|
|
|
|
// writePushError maps a push.Service error to its API error: an invalid
|
|
// subscription is 400 invalid_subscription, anything else 500.
|
|
func writePushError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
|
var invalid *push.ValidationError
|
|
if errors.As(err, &invalid) {
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_subscription", invalid.Error())
|
|
return
|
|
}
|
|
logger.ErrorContext(r.Context(), action, "error", err)
|
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
|
}
|