diff --git a/backend/internal/server/handlers_intake.go b/backend/internal/server/handlers_intake.go index de19c44..6c3184f 100644 --- a/backend/internal/server/handlers_intake.go +++ b/backend/internal/server/handlers_intake.go @@ -121,5 +121,6 @@ func (s *Server) handleRobokassaResult(c *gin.Context) { s.log.Error("record payment event failed", zap.String("order", orderID.String()), zap.Error(err)) } } - c.String(http.StatusOK, "OK"+v.Get("InvId")) + // The gateway echoes response verbatim to Robokassa, which requires the body "OK". + c.JSON(http.StatusOK, gin.H{"response": "OK" + v.Get("InvId")}) } diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 578bd81..1b19493 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -86,7 +86,7 @@ # The game SPA and the Connect edge are served by the gateway. Strip any # client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the # tag the honeypot block sets below (a client cannot self-tag a real request). - @gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /metrics/* /telemetry/* /scrabble.edge.v1.Gateway/* + @gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /pay/* /metrics/* /telemetry/* /scrabble.edge.v1.Gateway/* handle @gateway { reverse_proxy gateway:8081 { header_up -X-Scrabble-Honeypot diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 09f3352..8d033b4 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -414,6 +414,20 @@ func (c *Client) WalletOrder(ctx context.Context, userID, productID string) (Wal return out, err } +// robokassaResultResp is the backend intake's reply: the body to echo back to Robokassa. +type robokassaResultResp struct { + Response string `json:"response"` +} + +// RobokassaResult forwards a Robokassa Result callback's parameters to the backend intake (the +// single writer, which verifies the signature and credits) and returns the body to echo to +// Robokassa ("OK"). params carries the provider's raw form fields. +func (c *Client) RobokassaResult(ctx context.Context, params map[string]string) (string, error) { + var out robokassaResultResp + err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/robokassa/result", "", "", params, &out) + return out.Response, err +} + // CatalogAtomResp is one atom line of a storefront product: the value type it grants and quantity. type CatalogAtomResp struct { AtomType string `json:"atom_type"` diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index 704de92..2aaac25 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -196,6 +196,11 @@ func (s *Server) HTTPHandler() http.Handler { // through this session-gated route (not public); see dictBytesHandler. mux.Handle("/dict/", s.dictBytesHandler()) mux.Handle("/dl/", s.exportDownloadHandler()) + // Direct-rail (Robokassa) return + callback routes: the server Result callback (the single + // crediting signal, proxied to the backend intake) and the browser Success/Fail redirects. + mux.Handle("/pay/robokassa/result", s.robokassaResultHandler()) + mux.Handle("/pay/robokassa/success", s.robokassaRedirectHandler()) + mux.Handle("/pay/robokassa/fail", s.robokassaRedirectHandler()) // The client posts its local-move-preview adoption telemetry here (session-gated). mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler()) // The index.html boot guard beacons here when it turns a client away on the unsupported-engine @@ -482,6 +487,50 @@ func (s *Server) exportDownloadHandler() http.Handler { }) } +// robokassaResultHandler proxies the Robokassa Result callback to the backend intake (the single +// writer). It rate-limits per IP, forwards the provider's form parameters, and echoes the backend's +// "OK" to Robokassa on success; any error tells Robokassa the notification was not accepted, +// so it retries. The gateway does not verify the signature — the backend holds the secret. +func (s *Server) robokassaResultHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.backend == nil { + http.NotFound(w, r) + return + } + ip := peerIP(r.RemoteAddr, r.Header) + if !s.limiter.Allow("public:"+ip, s.publicPolicy) { + s.noteRateLimited(r.Context(), classPublic, ip, "robokassa-result") + http.Error(w, "rate limited", http.StatusTooManyRequests) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + params := make(map[string]string, len(r.Form)) + for k := range r.Form { + params[k] = r.Form.Get(k) + } + resp, err := s.backend.RobokassaResult(r.Context(), params) + if err != nil { + s.log.Warn("robokassa result proxy failed", zap.Error(err)) + http.Error(w, "not accepted", http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte(resp)) + }) +} + +// robokassaRedirectHandler redirects the customer's browser back into the app after Robokassa's +// Success/Fail return. The credit rides the server Result callback, never these redirects, so this +// only navigates home; the wallet reflects the credit once the callback lands. +func (s *Server) robokassaRedirectHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/app/", http.StatusSeeOther) + }) +} + func (s *Server) dictBytesHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if s.backend == nil {