package web import ( "io/fs" "log/slog" "net/http" "pete/internal/storage" ) // maxPushBodyBytes caps a subscription payload. A PushSubscription JSON is an // endpoint URL plus two short base64 keys — a few hundred bytes — so 4 KiB is // generous headroom for long endpoint URLs. const maxPushBodyBytes = 4096 // handlePushSubscribe stores the caller's Web Push subscription. The body is the // browser's PushSubscription.toJSON() shape: {endpoint, keys:{p256dh, auth}}. func (s *Server) handlePushSubscribe(w http.ResponseWriter, r *http.Request) { u := s.requireUser(w, r) if u == nil { return } if !s.cfg.Push.Enabled { http.Error(w, `{"error":"push disabled"}`, http.StatusNotFound) return } var req struct { Endpoint string `json:"endpoint"` Keys struct { P256dh string `json:"p256dh"` Auth string `json:"auth"` } `json:"keys"` } if !decodeStateBodyN(w, r, &req, maxPushBodyBytes) { return } if req.Endpoint == "" || req.Keys.P256dh == "" || req.Keys.Auth == "" { http.Error(w, `{"error":"incomplete subscription"}`, http.StatusBadRequest) return } if err := storage.AddPushSubscription(u.Sub, req.Endpoint, req.Keys.P256dh, req.Keys.Auth); err != nil { slog.Error("push: subscribe failed", "sub", u.Sub, "err", err) http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // handlePushUnsubscribe drops a stored subscription by endpoint. It doesn't // require the endpoint to belong to the caller beyond being signed in; the // endpoint is an unguessable capability URL, and dropping a stale one is benign. func (s *Server) handlePushUnsubscribe(w http.ResponseWriter, r *http.Request) { u := s.requireUser(w, r) if u == nil { return } var req struct { Endpoint string `json:"endpoint"` } if !decodeStateBodyN(w, r, &req, maxPushBodyBytes) { return } if req.Endpoint == "" { http.Error(w, `{"error":"missing endpoint"}`, http.StatusBadRequest) return } if err := storage.RemovePushSubscription(req.Endpoint); err != nil { slog.Error("push: unsubscribe failed", "sub", u.Sub, "err", err) http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // handleManifest serves the web app manifest from the embedded static tree. It // lives at the root so the installable scope covers the whole origin. func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) { s.serveEmbedded(w, r, "manifest.webmanifest", "application/manifest+json; charset=utf-8", "public, max-age=3600") } // handleServiceWorker serves /sw.js from the root. Serving it here rather than // under /static/ lets its scope be the whole origin (a worker's default scope // is its own path), and we set Service-Worker-Allowed as a belt-and-braces in // case it's ever moved. no-cache keeps updated workers from being pinned by the // HTTP cache — the browser still byte-compares to decide whether to install. func (s *Server) handleServiceWorker(w http.ResponseWriter, r *http.Request) { w.Header().Set("Service-Worker-Allowed", "/") s.serveEmbedded(w, r, "sw.js", "text/javascript; charset=utf-8", "no-cache") } // serveEmbedded writes a file from the embedded static FS with explicit headers. func (s *Server) serveEmbedded(w http.ResponseWriter, _ *http.Request, name, contentType, cacheControl string) { sub, err := fs.Sub(staticFS, "static") if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } b, err := fs.ReadFile(sub, name) if err != nil { http.NotFound(w, nil) return } w.Header().Set("Content-Type", contentType) w.Header().Set("Cache-Control", cacheControl) _, _ = w.Write(b) }