Initial public release of birdwatch-relay
This commit is contained in:
commit
c176f2ad24
17 changed files with 2025 additions and 0 deletions
114
internal/handlers/handlers.go
Normal file
114
internal/handlers/handlers.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue