mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-13 04:42:10 +00:00
feat: add workload-authenticated result API
This commit is contained in:
@@ -29,6 +29,10 @@ type ProbeProvider func(playerID, region string, opaqueLocation, nonce []byte, r
|
||||
type ProbeRecorder interface {
|
||||
RecordProbe(context.Context, string, string, time.Duration, time.Time) error
|
||||
}
|
||||
type WorkloadVerifier func(string, time.Time) (domain.WorkloadBinding, error)
|
||||
type ResultSubmitter interface {
|
||||
SubmitResult(context.Context, string, domain.MatchResult, domain.WorkloadBinding, []byte, time.Time) error
|
||||
}
|
||||
|
||||
type QueueBackend interface {
|
||||
Create(context.Context, string, string, string, domain.QueueSpec, time.Time) (domain.QueueTicket, error)
|
||||
@@ -90,6 +94,8 @@ type Service struct {
|
||||
CandidateIndex CandidateIndex
|
||||
Probe ProbeProvider
|
||||
ProbeRecorder ProbeRecorder
|
||||
WorkloadVerify WorkloadVerifier
|
||||
ResultSubmitter ResultSubmitter
|
||||
Assignment AssignmentProvider
|
||||
Now func() time.Time
|
||||
Proposals map[string]*domain.Proposal
|
||||
@@ -113,6 +119,7 @@ func (s *Service) Handler() http.Handler {
|
||||
mux.HandleFunc("/v1/profile/ranked", s.rankedProfile)
|
||||
mux.HandleFunc("/v1/probes/", s.probe)
|
||||
mux.HandleFunc("/v1/events", s.controlPlaneEvent)
|
||||
mux.HandleFunc("/v1/servers/", s.serverMutation)
|
||||
// The public contract is served below /api/v1. Keep the original /v1
|
||||
// routes for the Godot client while exposing the documented names.
|
||||
mux.HandleFunc("/api/v1/session/steam", s.steamSession)
|
||||
@@ -122,6 +129,7 @@ func (s *Service) Handler() http.Handler {
|
||||
mux.HandleFunc("/api/v1/proposals/", s.contractProposalMutation)
|
||||
mux.HandleFunc("/api/v1/assignments/", s.contractAssignment)
|
||||
mux.HandleFunc("/api/v1/events", s.controlPlaneEvent)
|
||||
mux.HandleFunc("/api/v1/servers/", s.contractServerMutation)
|
||||
if s.RateLimiter == nil {
|
||||
return mux
|
||||
}
|
||||
@@ -351,6 +359,82 @@ func (s *Service) contractAssignment(w http.ResponseWriter, r *http.Request) {
|
||||
s.assignment(w, clone)
|
||||
}
|
||||
|
||||
func (s *Service) contractServerMutation(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/servers/")
|
||||
if path == "" || strings.Contains(path, "/") {
|
||||
writeError(w, http.StatusNotFound, "not_found")
|
||||
return
|
||||
}
|
||||
clone := r.Clone(r.Context())
|
||||
clone.URL.Path = "/v1/servers/" + path
|
||||
s.serverMutation(w, clone)
|
||||
}
|
||||
|
||||
type resultRequest struct {
|
||||
MatchID string `json:"match_id"`
|
||||
ResultNonce string `json:"result_nonce"`
|
||||
Score struct {
|
||||
Team0 int `json:"team_0"`
|
||||
Team1 int `json:"team_1"`
|
||||
} `json:"score"`
|
||||
IntegrityState domain.IntegrityState `json:"integrity_state"`
|
||||
}
|
||||
|
||||
func (s *Service) serverMutation(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed")
|
||||
return
|
||||
}
|
||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/servers/"), "/")
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] != "result" {
|
||||
writeError(w, http.StatusNotFound, "not_found")
|
||||
return
|
||||
}
|
||||
if s.WorkloadVerify == nil || s.ResultSubmitter == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "result_unavailable")
|
||||
return
|
||||
}
|
||||
key := r.Header.Get("Idempotency-Key")
|
||||
if len(key) < 16 || len(key) > 128 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_idempotency_key")
|
||||
return
|
||||
}
|
||||
partsAuth := strings.Fields(r.Header.Get("Authorization"))
|
||||
if len(partsAuth) != 2 || partsAuth[0] != "Bearer" || partsAuth[1] == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
now := s.now()
|
||||
binding, err := s.WorkloadVerify(partsAuth[1], now)
|
||||
if err != nil || binding.ServerID != parts[0] {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
var input resultRequest
|
||||
if !decodeBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
if input.MatchID == "" || binding.MatchID != input.MatchID || len(input.ResultNonce) < 16 || len(input.ResultNonce) > 128 || input.Score.Team0 < 0 || input.Score.Team1 < 0 || (input.IntegrityState != domain.IntegrityCertified && input.IntegrityState != domain.IntegritySuppressed && input.IntegrityState != domain.IntegrityReview) {
|
||||
writeError(w, http.StatusUnprocessableEntity, "invalid_request")
|
||||
return
|
||||
}
|
||||
result := domain.MatchResult{MatchID: input.MatchID, ServerID: parts[0], ResultNonce: input.ResultNonce, Team0Score: input.Score.Team0, Team1Score: input.Score.Team1, IntegrityState: input.IntegrityState}
|
||||
payload, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request")
|
||||
return
|
||||
}
|
||||
if err := s.ResultSubmitter.SubmitResult(r.Context(), key, result, binding, payload, now); err != nil {
|
||||
if errors.Is(err, domain.ErrResultConflict) || strings.Contains(err.Error(), "conflict") {
|
||||
writeError(w, http.StatusConflict, "conflict")
|
||||
} else {
|
||||
writeError(w, http.StatusUnprocessableEntity, "invalid_request")
|
||||
}
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
|
||||
func (s *Service) queueMutation(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost && r.Method != http.MethodGet && r.Method != http.MethodDelete {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed")
|
||||
|
||||
Reference in New Issue
Block a user