package httpapi import ( "encoding/json" "net/http" "time" "commilitia.net/cdrop/internal/db" "commilitia.net/cdrop/internal/jwtauth" "commilitia.net/cdrop/internal/push" ) // maxAPNsRegisterBytes caps the register body. A device token is 64 hex chars // plus the locale field; 1 KB is generous headroom. const maxAPNsRegisterBytes = 1024 // maxPushSubscribeBytes caps the subscribe/unsubscribe body. A PushSubscription // JSON is well under 1 KB (endpoint URL + two short keys); 8 KB is generous // headroom while still bounding per-request memory. const maxPushSubscribeBytes = 8 * 1024 type vapidKeyResp struct { Enabled bool `json:"enabled"` PublicKey string `json:"public_key"` } // handlePushVAPIDKey hands the browser the VAPID public key it must pass as // applicationServerKey when calling pushManager.subscribe. The public key is // not a secret; enabled=false tells the client to hide the push UI entirely. func (s *Server) handlePushVAPIDKey(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, vapidKeyResp{ Enabled: s.push.Enabled(), PublicKey: s.push.PublicKey(), }) } // pushSubscribeReq mirrors PushSubscription.toJSON() plus the device's current // UI locale, so the server can render notification text the device can read. type pushSubscribeReq struct { Endpoint string `json:"endpoint"` Keys struct { P256dh string `json:"p256dh"` Auth string `json:"auth"` } `json:"keys"` Locale string `json:"locale"` } // knownLocales gates the locale we persist so a bogus value can't poison the // stored row; unknown / empty falls back to the server default. var knownLocales = map[string]bool{"zh-CN": true, "zh-TW": true, "en-US": true} // handlePushSubscribe stores (or refreshes) this browser's Web Push endpoint for // the calling device. Keyed by device_name so a notify-worthy event addressed to // a specific offline device pushes only to that device. Upsert by endpoint hash // makes a repeat subscribe idempotent. func (s *Server) handlePushSubscribe(w http.ResponseWriter, r *http.Request) { if !s.push.Enabled() { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "push disabled"}) return } claims, _ := jwtauth.ClaimsFromContext(r.Context()) device, _ := jwtauth.DeviceNameFromContext(r.Context()) if device == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device"}) return } r.Body = http.MaxBytesReader(w, r.Body, maxPushSubscribeBytes) var req pushSubscribeReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if req.Endpoint == "" || req.Keys.P256dh == "" || req.Keys.Auth == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "incomplete subscription"}) return } locale := req.Locale if !knownLocales[locale] { locale = "zh-CN" } now := time.Now().Unix() if err := s.queries.UpsertPushSubscription(r.Context(), db.UpsertPushSubscriptionParams{ ID: push.SubscriptionID(req.Endpoint), UserID: claims.UserID, DeviceName: device, Endpoint: req.Endpoint, P256dh: req.Keys.P256dh, Auth: req.Keys.Auth, UserAgent: truncate(r.UserAgent(), 256), Locale: locale, CreatedAt: now, LastUsedAt: now, }); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "store failed"}) return } w.WriteHeader(http.StatusNoContent) } // apnsRegisterReq is the body for POST /api/push/apns/register. // DeviceToken is the hex-encoded APNs device token the iOS app received from // the system. Locale is optional; unknown or empty falls back to the server default. type apnsRegisterReq struct { DeviceToken string `json:"device_token"` Locale string `json:"locale"` } // handleAPNsRegister stores (or refreshes) an iOS device's APNs token for the // calling session's device. Idempotent: re-registering the same token upserts // in place (SHA-256 of the token is the primary key). Returns 204 on success or // 503 when APNs is not configured. func (s *Server) handleAPNsRegister(w http.ResponseWriter, r *http.Request) { if !s.apns.Enabled() { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "apns disabled"}) return } claims, _ := jwtauth.ClaimsFromContext(r.Context()) device, _ := jwtauth.DeviceNameFromContext(r.Context()) if device == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device"}) return } r.Body = http.MaxBytesReader(w, r.Body, maxAPNsRegisterBytes) var req apnsRegisterReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if req.DeviceToken == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device_token"}) return } locale := req.Locale if !knownLocales[locale] { locale = "zh-CN" } now := time.Now().Unix() if err := s.queries.UpsertAPNsSubscription(r.Context(), db.UpsertAPNsSubscriptionParams{ ID: push.SubscriptionID(req.DeviceToken), UserID: claims.UserID, DeviceName: device, Endpoint: req.DeviceToken, Locale: locale, CreatedAt: now, LastUsedAt: now, }); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "store failed"}) return } w.WriteHeader(http.StatusNoContent) } type pushUnsubscribeReq struct { Endpoint string `json:"endpoint"` } // handlePushUnsubscribe drops a subscription the browser has revoked. Deleting by // endpoint hash needs no auth coupling beyond the session — the worst a wrong // endpoint does is delete a row the caller doesn't own, and endpoints are // unguessable capabilities, so this is not a meaningful enumeration vector. func (s *Server) handlePushUnsubscribe(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxPushSubscribeBytes) var req pushUnsubscribeReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Endpoint == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing endpoint"}) return } if err := s.queries.DeletePushSubscription(r.Context(), push.SubscriptionID(req.Endpoint)); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "delete failed"}) return } w.WriteHeader(http.StatusNoContent) }