// SPDX-License-Identifier: AGPL-3.0-or-later package handlers import ( "bytes" "crypto/subtle" "encoding/base64" "encoding/json" "errors" "log/slog" "net/http" "strings" "time" "github.com/cyrilinait/birdwatch-relay/internal/config" "github.com/cyrilinait/birdwatch-relay/internal/storage" ) type Handlers struct { store *storage.Store cfg *config.Config } func Register(mux *http.ServeMux, store *storage.Store, cfg *config.Config) { h := &Handlers{store: store, cfg: cfg} mux.HandleFunc("/v1/devices", h.registerDevice) mux.HandleFunc("/v1/health", h.health) } // --- /v1/health ----------------------------------------------------------- func (h *Handlers) health(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) } // --- /v1/devices ---------------------------------------------------------- type registerRequest struct { DeviceID string `json:"device_id"` FCMToken string `json:"fcm_token"` PublicKey string `json:"public_key"` // base64 X25519, 32 bytes raw } func (h *Handlers) registerDevice(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if !h.checkBearer(r, h.cfg.PairingCode) { http.Error(w, "unauthorized — invalid pairing code", http.StatusUnauthorized) return } var req registerRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } if req.DeviceID == "" || req.FCMToken == "" || req.PublicKey == "" { http.Error(w, "missing fields", http.StatusBadRequest) return } pub, err := base64.StdEncoding.DecodeString(req.PublicKey) if err != nil || len(pub) != 32 { http.Error(w, "public_key must be base64-encoded 32-byte X25519", http.StatusBadRequest) return } // Anti-hijack: existing device_id must keep the same public key. existing, getErr := h.store.Get(req.DeviceID) switch { case getErr == nil: if !bytes.Equal(existing.PublicKey, pub) { http.Error(w, "device_id already registered with a different public key — operator must delete the existing record to allow re-registration", http.StatusConflict) return } case errors.Is(getErr, storage.ErrNotFound): // fall through; new device. default: slog.Error("store get failed", "err", getErr, "device_id", req.DeviceID) http.Error(w, "storage error", http.StatusInternalServerError) return } now := time.Now().UTC() if err := h.store.Upsert(storage.Device{ ID: req.DeviceID, FCMToken: req.FCMToken, PublicKey: pub, CreatedAt: now, LastSeenAt: now, }); err != nil { slog.Error("device upsert failed", "err", err, "device_id", req.DeviceID) http.Error(w, "storage error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } func (h *Handlers) checkBearer(r *http.Request, want string) bool { got := r.Header.Get("Authorization") const prefix = "Bearer " if !strings.HasPrefix(got, prefix) { return false } have := strings.TrimPrefix(got, prefix) if len(have) != len(want) { return false } return subtle.ConstantTimeCompare([]byte(have), []byte(want)) == 1 }