Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions internal/v4/httpapi/relay.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package httpapi

import (
"errors"
"io"
"net/http"
"strings"

"github.com/Phloraxx/payment-api/internal/v4/relay"
)

const relayHTTPBodyLimit = 64 << 10

const (
relayDeviceHeader = "X-PayGate-Relay-Device"
relayTimeHeader = "X-PayGate-Relay-Time"
relaySignatureHeader = "X-PayGate-Relay-Signature"
)

type RelayHandler struct {
Relay *relay.Service
mux *http.ServeMux
}

func NewRelayHandler(service *relay.Service) *RelayHandler {
h := &RelayHandler{Relay: service, mux: http.NewServeMux()}
h.mux.HandleFunc("POST /api/v4/relay/pair", h.pair)
h.mux.HandleFunc("POST "+relay.EventPath, h.event)
h.mux.HandleFunc("POST "+relay.HeartbeatPath, h.heartbeat)
return h
}
func (h *RelayHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
if h == nil || h.Relay == nil || h.mux == nil {
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "PayGate relay is unavailable")
return
}
if r.URL.RawQuery != "" {
writeError(w, http.StatusBadRequest, "invalid_request", "Relay endpoints do not accept query parameters")
return
}
h.mux.ServeHTTP(w, r)
}

type relayPairRequest struct {
Token string `json:"token"`
Name string `json:"name"`
PublicKeyPEM string `json:"public_key_pem"`
AppVersion string `json:"app_version,omitempty"`
DeviceModel string `json:"device_model,omitempty"`
AndroidVersion string `json:"android_version,omitempty"`
}

func (h *RelayHandler) pair(w http.ResponseWriter, r *http.Request) {
if !isJSON(r.Header.Get("Content-Type")) {
writeError(w, http.StatusUnsupportedMediaType, "invalid_content_type", "Content-Type must be application/json")
return
}
var input relayPairRequest
if err := decodeStrictJSON(w, r, &input); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
result, err := h.Relay.PairDevice(r.Context(), relay.PairDeviceInput{
Token: input.Token, Name: input.Name, PublicKeyPEM: input.PublicKeyPEM,
AppVersion: input.AppVersion, DeviceModel: input.DeviceModel, AndroidVersion: input.AndroidVersion,
})
if err != nil {
writeRelayPairError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"device_id": result.DeviceID, "enabled": result.Enabled,
"replaced_device_id": emptyToNil(result.ReplacedDeviceID),
})
}

func (h *RelayHandler) event(w http.ResponseWriter, r *http.Request) {
raw, ok := readRelayBody(w, r)
if !ok {
return
}
result, err := h.Relay.IngestSigned(r.Context(), relayAuth(r, relay.EventPath), raw)
if err != nil {
writeRelayError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
}

func (h *RelayHandler) heartbeat(w http.ResponseWriter, r *http.Request) {
raw, ok := readRelayBody(w, r)
if !ok {
return
}
result, err := h.Relay.HeartbeatSigned(r.Context(), relayAuth(r, relay.HeartbeatPath), raw)
if err != nil {
writeRelayError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
}

func relayAuth(r *http.Request, path string) relay.RequestAuth {
return relay.RequestAuth{
DeviceID: r.Header.Get(relayDeviceHeader), Timestamp: r.Header.Get(relayTimeHeader),
Signature: r.Header.Get(relaySignatureHeader), Method: r.Method, Path: path,
}
}

func readRelayBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
if !isJSON(r.Header.Get("Content-Type")) {
writeError(w, http.StatusUnsupportedMediaType, "invalid_content_type", "Content-Type must be application/json")
return nil, false
}
r.Body = http.MaxBytesReader(w, r.Body, relayHTTPBodyLimit)
raw, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "Relay body is too large or unreadable")
return nil, false
}
return raw, true
}
func writeRelayPairError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, relay.ErrPairingTokenInvalid), errors.Is(err, relay.ErrPairingTokenExpired), errors.Is(err, relay.ErrPairingTokenUsed):
writeError(w, http.StatusUnauthorized, "invalid_pairing", "Pairing link is invalid or expired")
case errors.Is(err, relay.ErrRelayAlreadyActive):
writeError(w, http.StatusConflict, "device_already_connected", "A PayGate phone is already connected")
case errors.Is(err, relay.ErrInvalidDevice):
writeError(w, http.StatusBadRequest, "invalid_device", err.Error())
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Could not connect PayGate phone")
}
}

func writeRelayError(w http.ResponseWriter, err error) {
var relayErr *relay.Error
if errors.As(err, &relayErr) {
writeError(w, relayErr.HTTPStatus, strings.ToLower(relayErr.Code), relayErr.Message)
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "PayGate could not process the relay request")
}

func emptyToNil(value string) any {
if strings.TrimSpace(value) == "" {
return nil
}
return value
}
146 changes: 146 additions & 0 deletions internal/v4/httpapi/relay_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package httpapi

import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"time"

"github.com/Phloraxx/payment-api/internal/v4/payments"
"github.com/Phloraxx/payment-api/internal/v4/relay"
"github.com/Phloraxx/payment-api/internal/v4/storage"
)

type relayHTTPFixture struct {
db *storage.DB
service *relay.Service
handler *RelayHandler
private *ecdsa.PrivateKey
device string
now time.Time
}

func newRelayHTTPFixture(t *testing.T) relayHTTPFixture {
t.Helper()
db, err := storage.Open(context.Background(), filepath.Join(t.TempDir(), "paygate.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
paymentService := payments.NewService(db)
service := relay.NewService(db, paymentService)
now := time.Date(2026, 9, 1, 6, 30, 0, 0, time.UTC)
service.Now = func() time.Time { return now }

private, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
der, err := x509.MarshalPKIXPublicKey(&private.PublicKey)
if err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(der)
deviceID := hex.EncodeToString(sum[:])
return relayHTTPFixture{db: db, service: service, handler: NewRelayHandler(service), private: private, device: deviceID, now: now}
}
func (f relayHTTPFixture) publicKeyPEM(t *testing.T) string {
t.Helper()
der, err := x509.MarshalPKIXPublicKey(&f.private.PublicKey)
if err != nil {
t.Fatal(err)
}
return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}))
}

func pairRelayHTTP(t *testing.T, f relayHTTPFixture) {
t.Helper()
session, err := f.service.CreatePairing(context.Background(), false)
if err != nil {
t.Fatal(err)
}
body, _ := json.Marshal(map[string]any{
"token": session.Token, "name": "Edge 60 Stylus", "public_key_pem": f.publicKeyPEM(t),
"app_version": "0.5.0", "device_model": "motorola edge 60 stylus", "android_version": "16",
})
req := httptest.NewRequest(http.MethodPost, "/api/v4/relay/pair", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
f.handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), f.device) {
t.Fatalf("pair status=%d body=%s", rr.Code, rr.Body.String())
}
}
func signedRelayRequest(t *testing.T, f relayHTTPFixture, path string, body []byte) *http.Request {
t.Helper()
timestamp := strconv.FormatInt(f.now.UnixMilli(), 10)
canonical := relay.CanonicalRequest(http.MethodPost, path, timestamp, body)
digest := sha256.Sum256([]byte(canonical))
signature, err := ecdsa.SignASN1(rand.Reader, f.private, digest[:])
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set(relayDeviceHeader, f.device)
req.Header.Set(relayTimeHeader, timestamp)
req.Header.Set(relaySignatureHeader, base64.StdEncoding.EncodeToString(signature))
return req
}

func TestRelayPairHeartbeatAndHealthPersistence(t *testing.T) {
f := newRelayHTTPFixture(t)
pairRelayHTTP(t, f)
body := []byte(`{"schema_version":1,"app_version":"0.5.0","android_version":"16","device_model":"motorola edge 60 stylus","notification_access":true,"listener_connected":true,"battery_optimization_exempt":true,"power_save_mode":false,"background_restricted":false,"foreground_service":true,"pending_count":0,"failed_count":2}`)
rr := httptest.NewRecorder()
f.handler.ServeHTTP(rr, signedRelayRequest(t, f, relay.HeartbeatPath, body))
if rr.Code != http.StatusOK {
t.Fatalf("heartbeat status=%d body=%s", rr.Code, rr.Body.String())
}
device, err := f.service.ActiveDevice(context.Background())
if err != nil || device == nil || device.LastHeartbeatAt == nil || device.NotificationAccess == nil || !*device.NotificationAccess || device.FailedCount == nil || *device.FailedCount != 2 {
t.Fatalf("device=%+v err=%v", device, err)
}
}
func TestRelaySignedEventAndSignatureFailure(t *testing.T) {
f := newRelayHTTPFixture(t)
pairRelayHTTP(t, f)
body := []byte(`{"schema_version":1,"event_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","package_name":"com.paytm.business","posted_at_ms":1788244200000,"title":"Payment Received on Paytm for Business","text":"₹100.00 Received from Test"}`)
rr := httptest.NewRecorder()
f.handler.ServeHTTP(rr, signedRelayRequest(t, f, relay.EventPath, body))
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), `"status":"ignored"`) {
t.Fatalf("event status=%d body=%s", rr.Code, rr.Body.String())
}

bad := signedRelayRequest(t, f, relay.EventPath, body)
bad.Header.Set(relaySignatureHeader, base64.StdEncoding.EncodeToString([]byte("bad")))
rr = httptest.NewRecorder()
f.handler.ServeHTTP(rr, bad)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("bad signature status=%d body=%s", rr.Code, rr.Body.String())
}
}

func TestRelayRejectsQueryParameters(t *testing.T) {
f := newRelayHTTPFixture(t)
req := httptest.NewRequest(http.MethodPost, "/api/v4/relay/pair?token=leak", strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
f.handler.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("query status=%d body=%s", rr.Code, rr.Body.String())
}
}
5 changes: 3 additions & 2 deletions internal/v4/operator/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,20 +187,21 @@ func (s *Service) loadRelay(ctx context.Context, out *RelaySummary) error {
var name string
var lastSeen sql.NullInt64
var appVersion sql.NullString
err := s.DB.SQL.QueryRowContext(ctx, `SELECT COALESCE(name,''),last_seen_at,app_version FROM relay_devices WHERE enabled=1 LIMIT 1`).
err := s.DB.SQL.QueryRowContext(ctx, `SELECT COALESCE(name,''),COALESCE(last_heartbeat_at,last_seen_at),app_version FROM relay_devices WHERE enabled=1 LIMIT 1`).
Scan(&name, &lastSeen, &appVersion)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
return fmt.Errorf("read relay summary: %w", err)
}
out.Connected = true
out.Name = name
out.AppVersion = appVersion.String
if lastSeen.Valid {
value := time.UnixMilli(lastSeen.Int64).UTC()
out.LastSeenAt = &value
age := s.now().Sub(value)
out.Connected = age >= -5*time.Minute && age <= time.Hour
}
return nil
}
Expand Down
Loading