Files
stats-gateway/internal/service/recordspeedtest.go
T

91 lines
2.8 KiB
Go

package service
import (
"context"
"strings"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
statsv1 "github.com/bergabruh/stats-gateway/gen/stats/v1"
"github.com/bergabruh/stats-gateway/internal/storage"
)
// RecordSpeedTest ingests a single client-side speedtest sample from an edge
// node. Authenticates via Bearer JWT (same scheme as Tunnel), validates the
// request, and persists via the storage layer.
func (s *Service) RecordSpeedTest(ctx context.Context, req *statsv1.RecordSpeedTestRequest) (*statsv1.RecordSpeedTestResponse, error) {
// 1. JWT auth — mirrors the Tunnel handler.
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "no metadata")
}
authH := md.Get("authorization")
if len(authH) == 0 {
return nil, status.Error(codes.Unauthenticated, "no auth")
}
tok := strings.TrimPrefix(authH[0], "Bearer ")
if tok == authH[0] {
return nil, status.Error(codes.Unauthenticated, "expected Bearer")
}
if _, err := s.verifier.Verify(tok); err != nil {
return nil, status.Errorf(codes.Unauthenticated, "auth: %v", err)
}
// 2. Input validation.
if req.Node != "fl" && req.Node != "pl1" {
return nil, status.Errorf(codes.InvalidArgument, "node: must be 'fl' or 'pl1', got %q", req.Node)
}
if !validCountry(req.Country) {
return nil, status.Errorf(codes.InvalidArgument, "country: must match ^[A-Z]{2}$ or 'XX', got %q", req.Country)
}
if req.DlMbps < 0 {
return nil, status.Errorf(codes.InvalidArgument, "dl_mbps: must be >= 0, got %v", req.DlMbps)
}
if req.UlMbps < 0 {
return nil, status.Errorf(codes.InvalidArgument, "ul_mbps: must be >= 0, got %v", req.UlMbps)
}
if req.PingMs < 0 {
return nil, status.Errorf(codes.InvalidArgument, "ping_ms: must be >= 0, got %v", req.PingMs)
}
if req.JitterMs < 0 {
return nil, status.Errorf(codes.InvalidArgument, "jitter_ms: must be >= 0, got %v", req.JitterMs)
}
if req.Timestamp <= 0 {
return nil, status.Errorf(codes.InvalidArgument, "timestamp: must be > 0, got %d", req.Timestamp)
}
// 3. Persist.
id, err := s.store.SaveSpeedTest(ctx, storage.SpeedTest{
Timestamp: req.Timestamp,
Node: req.Node,
Country: req.Country,
DlMbps: req.DlMbps,
UlMbps: req.UlMbps,
PingMs: req.PingMs,
JitterMs: req.JitterMs,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "save: %v", err)
}
return &statsv1.RecordSpeedTestResponse{Id: id}, nil
}
// validCountry returns true when c is two uppercase ASCII letters (ISO-3166-1
// alpha-2 shape) or the literal "XX" sentinel for unknown.
func validCountry(c string) bool {
if len(c) != 2 {
return false
}
if c == "XX" {
return true
}
for i := 0; i < 2; i++ {
if c[i] < 'A' || c[i] > 'Z' {
return false
}
}
return true
}