package httpapi import ( "context" "encoding/json" "errors" "net/http" "time" "commilitia.net/cdrop/internal/hub" "commilitia.net/cdrop/internal/jwtauth" "commilitia.net/cdrop/internal/push" ) // 4 KB caps DoS-via-paste; longer payloads should use file transfer instead. const maxMessageBytes = 4 * 1024 // maxMessageBodyBytes bounds the raw request body BEFORE decode (R5): the 4 KB // limit above checks the decoded Go string, so on its own the whole body is // still read into memory first. The wire form can be larger than the decoded // text — JSON escaping inflates quotes/control chars (worst case ~6×) — so the // body cap is the content cap with headroom, not equal to it, to avoid falsely // rejecting a legitimate 4 KB message while still bounding memory per request. const maxMessageBodyBytes = maxMessageBytes * 8 type messageReq struct { To string `json:"to"` Text string `json:"text"` } // handleMessage forwards a one-shot text payload to a peer device. Reuses the // SSE Hub the same way /api/hub/signal does — no DB row, no transfer state // machine. Receiver sees a `message` event; the frontend keeps it in memory // only until the tab closes (per user spec). // // 204 if delivered live, 202 if the peer is offline but a Web Push was sent // (the notification carries the text — the message itself is not persisted), // 410 if offline with no push subscription, 413 if oversized. func (s *Server) handleMessage(w http.ResponseWriter, r *http.Request) { claims, _ := jwtauth.ClaimsFromContext(r.Context()) from, _ := jwtauth.DeviceNameFromContext(r.Context()) r.Body = http.MaxBytesReader(w, r.Body, maxMessageBodyBytes) var req messageReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { var mbe *http.MaxBytesError if errors.As(err, &mbe) { writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "message exceeds 4 KB"}) return } writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if req.To == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing 'to'"}) return } if req.Text == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "empty text"}) return } if len(req.Text) > maxMessageBytes { writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "message exceeds 4 KB"}) return } if req.To == from { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot send to self"}) return } delivered := s.hub.SendTo(claims.UserID, req.To, hub.Event{ Type: "message", Data: map[string]any{ "from": from, "text": req.Text, "sent_at": time.Now().Unix(), }, }) if !delivered { // Peer's page is closed (no live SSE). The message has no DB row, so the // only delivery left is a push notification (Web Push or APNs) carrying // the text itself. If any push channel is enabled and may have a // subscription, send and report 202; otherwise the message is truly gone. n := push.Notification{ Type: push.KindMessage, Params: map[string]string{"sender": from, "text": req.Text}, } if s.push.Enabled() || s.apns.Enabled() { if s.push.Enabled() { go s.push.Notify(context.Background(), claims.UserID, req.To, n) } if s.apns.Enabled() { go s.apns.Notify(context.Background(), claims.UserID, req.To, n) } w.WriteHeader(http.StatusAccepted) return } writeJSON(w, http.StatusGone, map[string]string{"error": "peer offline"}) return } w.WriteHeader(http.StatusNoContent) }