1174 lines
41 KiB
Go
1174 lines
41 KiB
Go
package customer
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
type CreditPackage struct {
|
|
ID string `json:"id"`
|
|
AmountCents int64 `json:"amount_cents"`
|
|
Currency string `json:"currency"`
|
|
CreditsMicros int64 `json:"credits_micros"`
|
|
}
|
|
|
|
type Config struct {
|
|
PublicAddr, AdminAddr, InternalAddr string
|
|
PublicBaseURL string
|
|
GamePublicURL, GameAdminURL, SharedSecret string
|
|
DockerHost, WorkerImage, WorkerEntrypoint, WorkerNetwork string
|
|
WorkerRegisterURL string
|
|
WorkerAutoPull bool
|
|
WorkerRateMicrosPerMinute int64
|
|
MaxWorkersPerCustomer int
|
|
MaxWorkersGlobal int
|
|
MaxRunningPerCustomer int
|
|
MaxRunningGlobal int
|
|
SessionTTL time.Duration
|
|
CookieSecure bool
|
|
AdminUser, AdminPassword string
|
|
AllowManualCredits bool
|
|
PayPalEnabled bool
|
|
PayPalEnvironment string
|
|
PayPalWebhookID string
|
|
PayPalLiveApprovalAck string
|
|
Packages []CreditPackage
|
|
}
|
|
|
|
type Service struct {
|
|
store *Store
|
|
docker *DockerClient
|
|
paypal *PayPalClient
|
|
cfg Config
|
|
adminSessions sync.Map
|
|
workerImageMu sync.Mutex
|
|
hc *http.Client
|
|
}
|
|
|
|
func NewService(store *Store, docker *DockerClient, paypal *PayPalClient, cfg Config) *Service {
|
|
if cfg.SessionTTL <= 0 {
|
|
cfg.SessionTTL = 24 * time.Hour
|
|
}
|
|
if cfg.WorkerRateMicrosPerMinute <= 0 {
|
|
cfg.WorkerRateMicrosPerMinute = 1_000_000
|
|
}
|
|
if cfg.MaxWorkersPerCustomer <= 0 {
|
|
cfg.MaxWorkersPerCustomer = 20
|
|
}
|
|
if cfg.MaxWorkersGlobal <= 0 {
|
|
cfg.MaxWorkersGlobal = 1000
|
|
}
|
|
if cfg.MaxRunningPerCustomer <= 0 {
|
|
cfg.MaxRunningPerCustomer = 10
|
|
}
|
|
if cfg.MaxRunningGlobal <= 0 {
|
|
cfg.MaxRunningGlobal = 100
|
|
}
|
|
return &Service{store: store, docker: docker, paypal: paypal, cfg: cfg, hc: &http.Client{Timeout: 10 * time.Second}}
|
|
}
|
|
|
|
func (s *Service) security(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("X-Frame-Options", "DENY")
|
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
|
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
|
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; form-action 'self' https://www.paypal.com https://www.sandbox.paypal.com")
|
|
if s.cfg.CookieSecure {
|
|
w.Header().Set("Strict-Transport-Security", "max-age=31536000")
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
func jsonOut(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
func decode(r *http.Request, v any) error {
|
|
d := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
|
d.DisallowUnknownFields()
|
|
return d.Decode(v)
|
|
}
|
|
|
|
const customerCookie = "neuralhunt_customer_session"
|
|
const csAdminCookie = "neuralhunt_customer_admin"
|
|
|
|
func (s *Service) setCookie(w http.ResponseWriter, name, value string, ttl time.Duration) {
|
|
http.SetCookie(w, &http.Cookie{Name: name, Value: value, Path: "/", HttpOnly: true, Secure: s.cfg.CookieSecure, SameSite: http.SameSiteStrictMode, MaxAge: int(ttl.Seconds())})
|
|
}
|
|
func (s *Service) clearCookie(w http.ResponseWriter, name string) {
|
|
http.SetCookie(w, &http.Cookie{Name: name, Value: "", Path: "/", HttpOnly: true, Secure: s.cfg.CookieSecure, SameSite: http.SameSiteStrictMode, MaxAge: -1})
|
|
}
|
|
func (s *Service) customerID(r *http.Request) (string, error) {
|
|
c, err := r.Cookie(customerCookie)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return s.store.SessionCustomer(r.Context(), c.Value)
|
|
}
|
|
func (s *Service) requireCustomer(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
cid, err := s.customerID(r)
|
|
if err != nil {
|
|
jsonOut(w, 401, map[string]string{"error": "unauthorized"})
|
|
return
|
|
}
|
|
ctx := context.WithValue(r.Context(), ctxKey("customer"), cid)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
type ctxKey string
|
|
|
|
func customerID(r *http.Request) string {
|
|
v, _ := r.Context().Value(ctxKey("customer")).(string)
|
|
return v
|
|
}
|
|
|
|
func (s *Service) PublicRoutes(ui http.Handler) http.Handler {
|
|
r := chi.NewRouter()
|
|
r.Use(s.security)
|
|
r.Get("/api/healthz", func(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, map[string]bool{"ok": true}) })
|
|
r.Post("/api/register", s.register)
|
|
r.Post("/api/login", s.login)
|
|
r.Post("/api/logout", s.logout)
|
|
r.Post("/api/paypal/webhook", s.paypalWebhook)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(s.requireCustomer)
|
|
r.Get("/api/me", s.me)
|
|
r.Get("/api/ledger", s.ledger)
|
|
r.Put("/api/reward-identity", s.rewardIdentity)
|
|
r.Get("/api/tasks", s.tasks)
|
|
r.Get("/api/workers", s.workers)
|
|
r.Post("/api/workers", s.createWorker)
|
|
r.Put("/api/workers/{id}", s.updateWorker)
|
|
r.Post("/api/workers/{id}/start", s.startWorker)
|
|
r.Post("/api/workers/{id}/stop", s.stopWorker)
|
|
r.Delete("/api/workers/{id}", s.deleteWorker)
|
|
r.Get("/api/workers/{id}/identity", s.workerIdentityGet)
|
|
r.Put("/api/workers/{id}/identity", s.workerIdentityPut)
|
|
r.Get("/api/billing/packages", s.billingPackages)
|
|
r.Post("/api/billing/paypal/order", s.paypalCreateOrder)
|
|
r.Post("/api/billing/paypal/capture", s.paypalCapture)
|
|
})
|
|
r.Mount("/", ui)
|
|
return r
|
|
}
|
|
func (s *Service) AdminRoutes(ui http.Handler) http.Handler {
|
|
r := chi.NewRouter()
|
|
r.Use(s.security)
|
|
r.Post("/api/admin/login", s.adminLogin)
|
|
r.Post("/api/admin/logout", s.adminLogout)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(s.requireAdmin)
|
|
r.Get("/api/admin/overview", s.adminOverview)
|
|
r.Post("/api/admin/credits/grant", s.adminCreditGrant)
|
|
})
|
|
r.Mount("/", ui)
|
|
return r
|
|
}
|
|
func (s *Service) InternalRoutes() http.Handler {
|
|
r := chi.NewRouter()
|
|
r.Use(s.security)
|
|
r.Post("/internal/workers/register", s.internalWorkerRegister)
|
|
r.Post("/internal/workers/lease", s.internalWorkerLease)
|
|
r.Get("/api/healthz", func(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, map[string]bool{"ok": true}) })
|
|
return r
|
|
}
|
|
|
|
func (s *Service) register(w http.ResponseWriter, r *http.Request) {
|
|
var in struct{ Username, Password string }
|
|
if decode(r, &in) != nil {
|
|
jsonOut(w, 400, map[string]string{"error": "bad json"})
|
|
return
|
|
}
|
|
in.Username = strings.TrimSpace(in.Username)
|
|
if len(in.Username) < 3 || len(in.Username) > 80 {
|
|
jsonOut(w, 400, map[string]string{"error": "username must be 3..80 characters"})
|
|
return
|
|
}
|
|
salt, hash, err := NewPasswordHash(in.Password)
|
|
if err != nil {
|
|
jsonOut(w, 400, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
cid := "cust_" + RandomToken(16)
|
|
if err := s.store.CreateCustomer(r.Context(), cid, in.Username, salt, hash); err != nil {
|
|
jsonOut(w, 409, map[string]string{"error": "username already exists"})
|
|
return
|
|
}
|
|
sid := RandomToken(32)
|
|
if err := s.store.CreateSession(r.Context(), sid, cid, s.cfg.SessionTTL); err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": "session failed"})
|
|
return
|
|
}
|
|
s.setCookie(w, customerCookie, sid, s.cfg.SessionTTL)
|
|
jsonOut(w, 201, map[string]string{"id": cid, "username": in.Username})
|
|
}
|
|
func (s *Service) login(w http.ResponseWriter, r *http.Request) {
|
|
var in struct{ Username, Password string }
|
|
if decode(r, &in) != nil {
|
|
jsonOut(w, 400, map[string]string{"error": "bad json"})
|
|
return
|
|
}
|
|
c, salt, hash, err := s.store.CustomerByUsername(r.Context(), in.Username)
|
|
if err != nil || !VerifyPassword(in.Password, salt, hash) {
|
|
time.Sleep(250 * time.Millisecond)
|
|
jsonOut(w, 401, map[string]string{"error": "invalid credentials"})
|
|
return
|
|
}
|
|
sid := RandomToken(32)
|
|
if err := s.store.CreateSession(r.Context(), sid, c.ID, s.cfg.SessionTTL); err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": "session failed"})
|
|
return
|
|
}
|
|
s.setCookie(w, customerCookie, sid, s.cfg.SessionTTL)
|
|
jsonOut(w, 200, map[string]any{"ok": true})
|
|
}
|
|
func (s *Service) logout(w http.ResponseWriter, r *http.Request) {
|
|
if c, err := r.Cookie(customerCookie); err == nil {
|
|
s.store.DeleteSession(r.Context(), c.Value)
|
|
}
|
|
s.clearCookie(w, customerCookie)
|
|
jsonOut(w, 200, map[string]bool{"ok": true})
|
|
}
|
|
func (s *Service) me(w http.ResponseWriter, r *http.Request) {
|
|
cid := customerID(r)
|
|
c, err := s.store.CustomerByID(r.Context(), cid)
|
|
if err != nil {
|
|
jsonOut(w, 404, map[string]string{"error": "customer missing"})
|
|
return
|
|
}
|
|
bal, _ := s.store.BalanceMicros(r.Context(), cid)
|
|
jsonOut(w, 200, map[string]any{"customer": c, "balance_micros": bal, "worker_rate_micros_per_minute": s.cfg.WorkerRateMicrosPerMinute, "paypal_enabled": s.paypalAllowed()})
|
|
}
|
|
func (s *Service) ledger(w http.ResponseWriter, r *http.Request) {
|
|
x, err := s.store.Ledger(r.Context(), customerID(r), 100)
|
|
if err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
jsonOut(w, 200, x)
|
|
}
|
|
|
|
func (s *Service) rewardIdentity(w http.ResponseWriter, r *http.Request) {
|
|
cid := customerID(r)
|
|
var in struct {
|
|
LinkCode string `json:"link_code"`
|
|
Clear bool `json:"clear"`
|
|
}
|
|
if decode(r, &in) != nil {
|
|
jsonOut(w, 400, map[string]string{"error": "bad json"})
|
|
return
|
|
}
|
|
clientID := ""
|
|
if !in.Clear {
|
|
if strings.TrimSpace(in.LinkCode) == "" {
|
|
jsonOut(w, 400, map[string]string{"error": "pairing code required; generate it while logged in with the reward identity"})
|
|
return
|
|
}
|
|
var err error
|
|
clientID, err = s.redeemRewardLink(r.Context(), in.LinkCode)
|
|
if err != nil {
|
|
jsonOut(w, 400, map[string]string{"error": "reward identity proof failed: " + err.Error()})
|
|
return
|
|
}
|
|
}
|
|
// Install delegations before committing the new owner locally. If any game
|
|
// control-plane call fails, the customer's previous reward owner remains
|
|
// unchanged and no half-applied portal state is presented as successful.
|
|
workers, _ := s.store.Workers(r.Context(), cid)
|
|
for _, wk := range workers {
|
|
if wk.WorkerClientID != "" {
|
|
if err := s.syncDelegation(r.Context(), wk.WorkerClientID, clientID); err != nil {
|
|
jsonOut(w, 400, map[string]string{"error": "reward identity could not be registered in game: " + err.Error()})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
if err := s.store.SetRewardClientID(r.Context(), cid, clientID); err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
jsonOut(w, 200, map[string]any{"ok": true, "reward_client_id": clientID})
|
|
}
|
|
|
|
func (s *Service) proxyGET(ctx context.Context, path string, out any) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(s.cfg.GamePublicURL, "/")+path, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp, err := s.hc.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
|
if resp.StatusCode/100 != 2 {
|
|
return fmt.Errorf("game HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
|
}
|
|
return json.Unmarshal(b, out)
|
|
}
|
|
func (s *Service) tasks(w http.ResponseWriter, r *http.Request) {
|
|
var out any
|
|
if err := s.proxyGET(r.Context(), "/api/public/tasks", &out); err != nil {
|
|
jsonOut(w, 502, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
jsonOut(w, 200, out)
|
|
}
|
|
func (s *Service) taskAvailable(ctx context.Context, taskID string) (bool, error) {
|
|
var tasks []struct {
|
|
ID string `json:"id"`
|
|
Paused bool `json:"paused"`
|
|
}
|
|
if err := s.proxyGET(ctx, "/api/public/tasks", &tasks); err != nil {
|
|
return false, err
|
|
}
|
|
for _, t := range tasks {
|
|
if t.ID == strings.TrimSpace(taskID) {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func normalizePath(v string) string {
|
|
v = strings.ToLower(strings.TrimSpace(v))
|
|
switch v {
|
|
case "pulse", "flux", "orbit", "auto":
|
|
return v
|
|
default:
|
|
return "auto"
|
|
}
|
|
}
|
|
func (s *Service) workers(w http.ResponseWriter, r *http.Request) {
|
|
x, err := s.store.Workers(r.Context(), customerID(r))
|
|
if err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
jsonOut(w, 200, x)
|
|
}
|
|
func (s *Service) createWorker(w http.ResponseWriter, r *http.Request) {
|
|
cid := customerID(r)
|
|
if n, err := s.store.WorkerCount(r.Context(), cid, false); err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": err.Error()})
|
|
return
|
|
} else if n >= s.cfg.MaxWorkersPerCustomer {
|
|
jsonOut(w, 409, map[string]string{"error": fmt.Sprintf("worker limit reached (%d)", s.cfg.MaxWorkersPerCustomer)})
|
|
return
|
|
}
|
|
if n, err := s.store.TotalWorkerCount(r.Context()); err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": err.Error()})
|
|
return
|
|
} else if n >= s.cfg.MaxWorkersGlobal {
|
|
jsonOut(w, 503, map[string]string{"error": "global hosted worker inventory limit reached"})
|
|
return
|
|
}
|
|
var in struct{ TaskID, BeaconPath string }
|
|
if decode(r, &in) != nil || strings.TrimSpace(in.TaskID) == "" {
|
|
jsonOut(w, 400, map[string]string{"error": "task_id required"})
|
|
return
|
|
}
|
|
available, err := s.taskAvailable(r.Context(), in.TaskID)
|
|
if err != nil {
|
|
jsonOut(w, 502, map[string]string{"error": "task catalog: " + err.Error()})
|
|
return
|
|
}
|
|
if !available {
|
|
jsonOut(w, 400, map[string]string{"error": "task is not active/available"})
|
|
return
|
|
}
|
|
wid := "wrk_" + RandomToken(12)
|
|
wk := Worker{ID: wid, CustomerID: cid, TaskID: strings.TrimSpace(in.TaskID), BeaconPath: normalizePath(in.BeaconPath), Volume: "nh_identity_" + strings.ReplaceAll(wid, "-", "_"), RegisterToken: RandomToken(24), RateMicrosPerMinute: s.cfg.WorkerRateMicrosPerMinute}
|
|
// Store the intended named-volume reference, but do not allocate Docker
|
|
// resources merely because an account created a stopped worker. Docker will
|
|
// create the named volume lazily when the worker is started or an identity is
|
|
// explicitly uploaded. This prevents free stopped-worker creation from
|
|
// consuming host volumes.
|
|
if err := s.store.CreateWorker(r.Context(), wk); err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
jsonOut(w, 201, wk)
|
|
}
|
|
func (s *Service) updateWorker(w http.ResponseWriter, r *http.Request) {
|
|
cid, wid := customerID(r), chi.URLParam(r, "id")
|
|
wk, err := s.store.Worker(r.Context(), cid, wid)
|
|
if err != nil {
|
|
jsonOut(w, 404, map[string]string{"error": "worker not found"})
|
|
return
|
|
}
|
|
var in struct{ TaskID, BeaconPath string }
|
|
if decode(r, &in) != nil || strings.TrimSpace(in.TaskID) == "" {
|
|
jsonOut(w, 400, map[string]string{"error": "task_id required"})
|
|
return
|
|
}
|
|
available, err := s.taskAvailable(r.Context(), in.TaskID)
|
|
if err != nil {
|
|
jsonOut(w, 502, map[string]string{"error": "task catalog: " + err.Error()})
|
|
return
|
|
}
|
|
if !available {
|
|
jsonOut(w, 400, map[string]string{"error": "task is not active/available"})
|
|
return
|
|
}
|
|
if wk.ContainerID != "" {
|
|
_ = s.docker.Stop(r.Context(), wk.ContainerID, 5)
|
|
_ = s.docker.Remove(r.Context(), wk.ContainerID)
|
|
_ = s.store.SetWorkerRuntime(r.Context(), wid, "stopped", "", "")
|
|
}
|
|
if err := s.store.UpdateWorkerConfig(r.Context(), cid, wid, strings.TrimSpace(in.TaskID), normalizePath(in.BeaconPath)); err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
jsonOut(w, 200, map[string]bool{"ok": true})
|
|
}
|
|
|
|
func (s *Service) ensurePaidSlice(ctx context.Context, wk Worker) (ok bool, newlyCharged bool, chargedAt time.Time, err error) {
|
|
now := time.Now().UTC()
|
|
if wk.LastChargeAt != nil && now.Sub(*wk.LastChargeAt) < time.Minute {
|
|
return true, false, time.Time{}, nil
|
|
}
|
|
ok, err = s.store.ChargeWorkerMinute(ctx, wk, now)
|
|
return ok, ok && err == nil, now, err
|
|
}
|
|
func (s *Service) workerContainerConfig(wk Worker, name string) WorkerContainerConfig {
|
|
registerURL := strings.TrimSpace(s.cfg.WorkerRegisterURL)
|
|
if registerURL == "" {
|
|
registerURL = "http://customer-service:8092/internal/workers/register"
|
|
}
|
|
return WorkerContainerConfig{Image: s.cfg.WorkerImage, Entrypoint: s.cfg.WorkerEntrypoint, Network: s.cfg.WorkerNetwork, GameURL: s.cfg.GamePublicURL, RegisterURL: registerURL, WorkerID: wk.ID, RegisterToken: wk.RegisterToken, TaskID: wk.TaskID, BeaconPath: wk.BeaconPath, Volume: wk.Volume, Name: name}
|
|
}
|
|
|
|
func (s *Service) ensureWorkerImage(ctx context.Context) error {
|
|
// Avoid several customer requests triggering concurrent pulls of the same
|
|
// image. The local inspect is cheap once the image is available.
|
|
s.workerImageMu.Lock()
|
|
defer s.workerImageMu.Unlock()
|
|
auth, err := RegistryAuthHeader(s.cfg.WorkerRegistryUsername, s.cfg.WorkerRegistryPassword, s.cfg.WorkerRegistryServer)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.docker.EnsureImage(ctx, s.cfg.WorkerImage, s.cfg.WorkerAutoPull, auth)
|
|
}
|
|
|
|
// ensureWorkerContainer makes the worker identity volume addressable through
|
|
// Docker's archive API even when a previous config change removed the old
|
|
// container. The helper container remains stopped until the customer starts it.
|
|
func (s *Service) ensureWorkerContainer(ctx context.Context, wk Worker) (Worker, error) {
|
|
if wk.ContainerID != "" {
|
|
return wk, nil
|
|
}
|
|
if err := s.ensureWorkerImage(ctx); err != nil {
|
|
return wk, err
|
|
}
|
|
name := "neuralhunt-worker-" + strings.TrimPrefix(wk.ID, "wrk_")
|
|
id, err := s.docker.CreateWorker(ctx, s.workerContainerConfig(wk, name))
|
|
if err != nil {
|
|
return wk, err
|
|
}
|
|
wk.ContainerID = id
|
|
if err := s.store.SetWorkerRuntime(ctx, wk.ID, "stopped", id, ""); err != nil {
|
|
_ = s.docker.Remove(ctx, id)
|
|
return wk, err
|
|
}
|
|
return wk, nil
|
|
}
|
|
|
|
func (s *Service) startWorker(w http.ResponseWriter, r *http.Request) {
|
|
cid, wid := customerID(r), chi.URLParam(r, "id")
|
|
wk, err := s.store.Worker(r.Context(), cid, wid)
|
|
if err != nil {
|
|
jsonOut(w, 404, map[string]string{"error": "worker not found"})
|
|
return
|
|
}
|
|
if wk.Status != "running" {
|
|
if n, _ := s.store.WorkerCount(r.Context(), cid, true); n >= s.cfg.MaxRunningPerCustomer {
|
|
jsonOut(w, 409, map[string]string{"error": fmt.Sprintf("running worker limit reached (%d)", s.cfg.MaxRunningPerCustomer)})
|
|
return
|
|
}
|
|
if n, _ := s.store.RunningWorkerCount(r.Context()); n >= s.cfg.MaxRunningGlobal {
|
|
jsonOut(w, 503, map[string]string{"error": "hosted worker capacity reached"})
|
|
return
|
|
}
|
|
}
|
|
available, err := s.taskAvailable(r.Context(), wk.TaskID)
|
|
if err != nil {
|
|
jsonOut(w, 502, map[string]string{"error": "task catalog: " + err.Error()})
|
|
return
|
|
}
|
|
if !available {
|
|
jsonOut(w, 409, map[string]string{"error": "assigned task is no longer active; choose another task"})
|
|
return
|
|
}
|
|
cust, err := s.store.CustomerByID(r.Context(), cid)
|
|
if err != nil || strings.TrimSpace(cust.RewardClientID) == "" {
|
|
jsonOut(w, 409, map[string]string{"error": "configure and verify a main reward identity before starting hosted workers"})
|
|
return
|
|
}
|
|
if err := s.ensureWorkerImage(r.Context()); err != nil {
|
|
jsonOut(w, 502, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
claimed, err := s.store.ClaimWorkerStart(r.Context(), cid, wid)
|
|
if err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
if !claimed {
|
|
jsonOut(w, 409, map[string]string{"error": "worker is already running or starting"})
|
|
return
|
|
}
|
|
ok, newlyCharged, chargedAt, err := s.ensurePaidSlice(r.Context(), wk)
|
|
if err != nil {
|
|
_ = s.store.SetWorkerRuntime(r.Context(), wid, "error", wk.ContainerID, err.Error())
|
|
jsonOut(w, 500, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
if !ok {
|
|
_ = s.store.SetWorkerRuntime(r.Context(), wid, "stopped", wk.ContainerID, "prepaid balance exhausted")
|
|
jsonOut(w, 402, map[string]string{"error": "prepaid balance exhausted"})
|
|
return
|
|
}
|
|
if wk.ContainerID != "" {
|
|
_ = s.docker.Remove(r.Context(), wk.ContainerID)
|
|
}
|
|
name := "neuralhunt-worker-" + strings.TrimPrefix(wid, "wrk_")
|
|
_ = s.docker.Remove(r.Context(), name) // clears a crash-orphan with the deterministic name
|
|
containerID, err := s.docker.CreateWorker(r.Context(), s.workerContainerConfig(wk, name))
|
|
if err == nil {
|
|
err = s.docker.Start(r.Context(), containerID)
|
|
}
|
|
if err != nil {
|
|
if containerID != "" {
|
|
_ = s.docker.Stop(r.Context(), containerID, 2)
|
|
_ = s.docker.Remove(r.Context(), containerID)
|
|
}
|
|
if newlyCharged {
|
|
_ = s.store.RefundWorkerStartMinute(r.Context(), wk, chargedAt, "docker_start_failed")
|
|
}
|
|
_ = s.store.SetWorkerRuntime(r.Context(), wid, "error", "", err.Error())
|
|
jsonOut(w, 502, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
if err := s.store.SetWorkerRuntime(r.Context(), wid, "running", containerID, ""); err != nil {
|
|
_ = s.docker.Stop(r.Context(), containerID, 2)
|
|
_ = s.docker.Remove(r.Context(), containerID)
|
|
if newlyCharged {
|
|
_ = s.store.RefundWorkerStartMinute(r.Context(), wk, chargedAt, "runtime_state_failed")
|
|
}
|
|
jsonOut(w, 500, map[string]string{"error": "worker state could not be persisted"})
|
|
return
|
|
}
|
|
jsonOut(w, 200, map[string]any{"ok": true, "container_id": containerID})
|
|
}
|
|
func (s *Service) stopWorker(w http.ResponseWriter, r *http.Request) {
|
|
cid, wid := customerID(r), chi.URLParam(r, "id")
|
|
wk, err := s.store.Worker(r.Context(), cid, wid)
|
|
if err != nil {
|
|
jsonOut(w, 404, map[string]string{"error": "worker not found"})
|
|
return
|
|
}
|
|
if err := s.stopWorkerInternal(r.Context(), wk, "stopped"); err != nil {
|
|
jsonOut(w, 502, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
jsonOut(w, 200, map[string]bool{"ok": true})
|
|
}
|
|
func (s *Service) stopWorkerInternal(ctx context.Context, wk Worker, status string) error {
|
|
if wk.ContainerID != "" {
|
|
if err := s.docker.Stop(ctx, wk.ContainerID, 5); err != nil && !strings.Contains(err.Error(), "304") {
|
|
return err
|
|
}
|
|
}
|
|
return s.store.SetWorkerRuntime(ctx, wk.ID, status, wk.ContainerID, "")
|
|
}
|
|
func (s *Service) deleteWorker(w http.ResponseWriter, r *http.Request) {
|
|
cid, wid := customerID(r), chi.URLParam(r, "id")
|
|
wk, err := s.store.Worker(r.Context(), cid, wid)
|
|
if err != nil {
|
|
jsonOut(w, 404, map[string]string{"error": "worker not found"})
|
|
return
|
|
}
|
|
if wk.ContainerID != "" {
|
|
_ = s.docker.Stop(r.Context(), wk.ContainerID, 3)
|
|
_ = s.docker.Remove(r.Context(), wk.ContainerID)
|
|
}
|
|
if wk.WorkerClientID != "" {
|
|
_ = s.syncDelegation(r.Context(), wk.WorkerClientID, "")
|
|
}
|
|
if err := s.store.DeleteWorker(r.Context(), cid, wid); err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
_ = s.docker.RemoveVolume(r.Context(), wk.Volume)
|
|
jsonOut(w, 200, map[string]bool{"ok": true})
|
|
}
|
|
func (s *Service) workerIdentityGet(w http.ResponseWriter, r *http.Request) {
|
|
wk, err := s.store.Worker(r.Context(), customerID(r), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
jsonOut(w, 404, map[string]string{"error": "worker not found"})
|
|
return
|
|
}
|
|
if wk.ContainerID == "" {
|
|
jsonOut(w, 409, map[string]string{"error": "worker identity does not exist yet; start the worker once or upload an identity first"})
|
|
return
|
|
}
|
|
b, err := s.docker.GetFile(r.Context(), wk.ContainerID, "/identity/identity.json")
|
|
if err != nil {
|
|
jsonOut(w, 502, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Content-Disposition", `attachment; filename="neuralhunt-worker-identity.json"`)
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_, _ = w.Write(b)
|
|
}
|
|
func (s *Service) workerIdentityPut(w http.ResponseWriter, r *http.Request) {
|
|
wk, err := s.store.Worker(r.Context(), customerID(r), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
jsonOut(w, 404, map[string]string{"error": "worker not found"})
|
|
return
|
|
}
|
|
wk, err = s.ensureWorkerContainer(r.Context(), wk)
|
|
if err != nil {
|
|
jsonOut(w, 502, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
b, err := io.ReadAll(io.LimitReader(r.Body, 256<<10))
|
|
if err != nil || len(b) == 0 {
|
|
jsonOut(w, 400, map[string]string{"error": "identity JSON required"})
|
|
return
|
|
}
|
|
if err := ValidateRawIdentity(b); err != nil {
|
|
jsonOut(w, 400, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
if wk.Status == "running" {
|
|
_ = s.docker.Stop(r.Context(), wk.ContainerID, 5)
|
|
}
|
|
if err := s.docker.PutFile(r.Context(), wk.ContainerID, "/identity", "identity.json", b); err != nil {
|
|
jsonOut(w, 502, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
_ = s.store.SetWorkerClient(r.Context(), wk.ID, "")
|
|
_ = s.store.SetWorkerRuntime(r.Context(), wk.ID, "stopped", wk.ContainerID, "")
|
|
jsonOut(w, 200, map[string]bool{"ok": true})
|
|
}
|
|
|
|
func (s *Service) authenticateWorkerInternal(r *http.Request, workerID string) (Worker, bool) {
|
|
wk, err := s.store.WorkerByID(r.Context(), workerID)
|
|
if err != nil {
|
|
return Worker{}, false
|
|
}
|
|
provided := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
|
if len(provided) != len(wk.RegisterToken) || subtle.ConstantTimeCompare([]byte(provided), []byte(wk.RegisterToken)) != 1 {
|
|
return Worker{}, false
|
|
}
|
|
return wk, true
|
|
}
|
|
|
|
func (s *Service) internalWorkerLease(w http.ResponseWriter, r *http.Request) {
|
|
var in struct {
|
|
WorkerID string `json:"worker_id"`
|
|
}
|
|
if decode(r, &in) != nil || strings.TrimSpace(in.WorkerID) == "" {
|
|
jsonOut(w, 400, map[string]string{"error": "worker_id required"})
|
|
return
|
|
}
|
|
wk, ok := s.authenticateWorkerInternal(r, in.WorkerID)
|
|
if !ok {
|
|
jsonOut(w, 401, map[string]string{"error": "unauthorized"})
|
|
return
|
|
}
|
|
if wk.Status != "running" && wk.Status != "starting" {
|
|
jsonOut(w, 409, map[string]string{"error": "worker lease revoked"})
|
|
return
|
|
}
|
|
jsonOut(w, 200, map[string]any{"ok": true, "lease_sec": 45})
|
|
}
|
|
|
|
func (s *Service) internalWorkerRegister(w http.ResponseWriter, r *http.Request) {
|
|
var in struct {
|
|
WorkerID string `json:"worker_id"`
|
|
ClientID string `json:"client_id"`
|
|
}
|
|
if decode(r, &in) != nil {
|
|
jsonOut(w, 400, map[string]string{"error": "bad json"})
|
|
return
|
|
}
|
|
wk, ok := s.authenticateWorkerInternal(r, in.WorkerID)
|
|
if !ok {
|
|
jsonOut(w, 401, map[string]string{"error": "unauthorized"})
|
|
return
|
|
}
|
|
if err := s.store.SetWorkerClient(r.Context(), wk.ID, in.ClientID); err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
cust, _ := s.store.CustomerByID(r.Context(), wk.CustomerID)
|
|
if err := s.syncDelegation(r.Context(), in.ClientID, cust.RewardClientID); err != nil {
|
|
_ = s.store.SetWorkerRuntime(r.Context(), wk.ID, "error", wk.ContainerID, "delegation failed: "+err.Error())
|
|
jsonOut(w, 502, map[string]string{"error": "delegation failed: " + err.Error()})
|
|
return
|
|
}
|
|
jsonOut(w, 200, map[string]bool{"ok": true})
|
|
}
|
|
func (s *Service) redeemRewardLink(ctx context.Context, code string) (string, error) {
|
|
if strings.TrimSpace(s.cfg.SharedSecret) == "" {
|
|
return "", errors.New("CUSTOMER_SERVICE_SHARED_SECRET missing")
|
|
}
|
|
body, _ := json.Marshal(map[string]string{"code": strings.TrimSpace(code)})
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(s.cfg.GameAdminURL, "/")+"/api/internal/customer-link/consume", strings.NewReader(string(body)))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+s.cfg.SharedSecret)
|
|
resp, err := s.hc.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
if resp.StatusCode/100 != 2 {
|
|
return "", fmt.Errorf("game pairing HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
|
}
|
|
var out struct {
|
|
ClientID string `json:"client_id"`
|
|
}
|
|
if err := json.Unmarshal(b, &out); err != nil {
|
|
return "", err
|
|
}
|
|
if strings.TrimSpace(out.ClientID) == "" {
|
|
return "", errors.New("game returned empty reward identity")
|
|
}
|
|
return strings.TrimSpace(out.ClientID), nil
|
|
}
|
|
|
|
func (s *Service) rewardIdentityExists(ctx context.Context, clientID string) (bool, error) {
|
|
if strings.TrimSpace(s.cfg.SharedSecret) == "" {
|
|
return false, errors.New("CUSTOMER_SERVICE_SHARED_SECRET missing")
|
|
}
|
|
body, _ := json.Marshal(map[string]string{"client_id": clientID})
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(s.cfg.GameAdminURL, "/")+"/api/internal/identity-exists", strings.NewReader(string(body)))
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+s.cfg.SharedSecret)
|
|
resp, err := s.hc.Do(req)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer resp.Body.Close()
|
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
if resp.StatusCode/100 != 2 {
|
|
return false, fmt.Errorf("game identity HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
|
}
|
|
var out struct {
|
|
Exists bool `json:"exists"`
|
|
}
|
|
if err := json.Unmarshal(b, &out); err != nil {
|
|
return false, err
|
|
}
|
|
return out.Exists, nil
|
|
}
|
|
|
|
func (s *Service) syncDelegation(ctx context.Context, worker, owner string) error {
|
|
if strings.TrimSpace(s.cfg.SharedSecret) == "" {
|
|
return errors.New("CUSTOMER_SERVICE_SHARED_SECRET missing")
|
|
}
|
|
body, _ := json.Marshal(map[string]string{"worker_client_id": worker, "owner_client_id": owner})
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(s.cfg.GameAdminURL, "/")+"/api/internal/delegations", strings.NewReader(string(body)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+s.cfg.SharedSecret)
|
|
resp, err := s.hc.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
if resp.StatusCode/100 != 2 {
|
|
return fmt.Errorf("game delegation HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) RunBilling(ctx context.Context) {
|
|
ticker := time.NewTicker(2 * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
s.billOnce(ctx)
|
|
}
|
|
}
|
|
}
|
|
func (s *Service) billOnce(ctx context.Context) {
|
|
workers, err := s.store.RunningWorkers(ctx)
|
|
if err != nil {
|
|
log.Printf("customer billing: %v", err)
|
|
return
|
|
}
|
|
now := time.Now().UTC()
|
|
for _, wk := range workers {
|
|
if wk.ContainerID == "" {
|
|
continue
|
|
}
|
|
running, err := s.docker.Running(ctx, wk.ContainerID)
|
|
if err != nil || !running {
|
|
_ = s.store.SetWorkerRuntime(ctx, wk.ID, "stopped", wk.ContainerID, "")
|
|
continue
|
|
}
|
|
last := wk.LastChargeAt
|
|
if last == nil {
|
|
t := now
|
|
last = &t
|
|
}
|
|
for now.Sub(*last) >= time.Minute {
|
|
next := last.Add(time.Minute)
|
|
ok, err := s.store.ChargeWorkerMinute(ctx, wk, next)
|
|
if err != nil {
|
|
log.Printf("billing worker %s: %v", wk.ID, err)
|
|
break
|
|
}
|
|
if !ok {
|
|
_ = s.stopWorkerInternal(ctx, wk, "stopped")
|
|
log.Printf("billing worker %s stopped: prepaid balance exhausted", wk.ID)
|
|
break
|
|
}
|
|
last = &next
|
|
wk.LastChargeAt = last
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) billingPackages(w http.ResponseWriter, r *http.Request) {
|
|
jsonOut(w, 200, map[string]any{"paypal_enabled": s.paypalAllowed(), "environment": s.cfg.PayPalEnvironment, "packages": s.cfg.Packages})
|
|
}
|
|
func (s *Service) paypalAllowed() bool {
|
|
if !s.cfg.PayPalEnabled || s.paypal == nil || !s.paypal.Ready() {
|
|
return false
|
|
}
|
|
if strings.EqualFold(s.cfg.PayPalEnvironment, "live") && s.cfg.PayPalLiveApprovalAck != "I_HAVE_PAYPAL_APPROVAL" {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
func (s *Service) packageByID(id string) (CreditPackage, bool) {
|
|
for _, p := range s.cfg.Packages {
|
|
if p.ID == id {
|
|
return p, true
|
|
}
|
|
}
|
|
return CreditPackage{}, false
|
|
}
|
|
func centsValue(c int64) string {
|
|
return fmt.Sprintf("%d.%02d", c/100, int64(math.Abs(float64(c%100))))
|
|
}
|
|
func parseMoneyCents(v string) (int64, error) {
|
|
v = strings.TrimSpace(v)
|
|
if v == "" || strings.HasPrefix(v, "-") || strings.HasPrefix(v, "+") {
|
|
return 0, errors.New("invalid money value")
|
|
}
|
|
whole, frac, hasDot := strings.Cut(v, ".")
|
|
if whole == "" {
|
|
whole = "0"
|
|
}
|
|
if hasDot {
|
|
if len(frac) > 2 {
|
|
return 0, errors.New("money value has more than two decimals")
|
|
}
|
|
for len(frac) < 2 {
|
|
frac += "0"
|
|
}
|
|
} else {
|
|
frac = "00"
|
|
}
|
|
w, err := strconv.ParseInt(whole, 10, 64)
|
|
if err != nil || w < 0 {
|
|
return 0, errors.New("invalid money value")
|
|
}
|
|
f, err := strconv.ParseInt(frac, 10, 64)
|
|
if err != nil || f < 0 || f > 99 {
|
|
return 0, errors.New("invalid money value")
|
|
}
|
|
if w > (math.MaxInt64-f)/100 {
|
|
return 0, errors.New("money value too large")
|
|
}
|
|
return w*100 + f, nil
|
|
}
|
|
func (s *Service) paypalCreateOrder(w http.ResponseWriter, r *http.Request) {
|
|
if !s.paypalAllowed() {
|
|
jsonOut(w, 503, map[string]string{"error": "PayPal is disabled or live approval acknowledgement is missing"})
|
|
return
|
|
}
|
|
var in struct {
|
|
PackageID string `json:"package_id"`
|
|
}
|
|
if decode(r, &in) != nil {
|
|
jsonOut(w, 400, map[string]string{"error": "bad json"})
|
|
return
|
|
}
|
|
pkg, ok := s.packageByID(in.PackageID)
|
|
if !ok {
|
|
jsonOut(w, 400, map[string]string{"error": "unknown package"})
|
|
return
|
|
}
|
|
cid := customerID(r)
|
|
base := strings.TrimRight(s.cfg.PublicBaseURL, "/")
|
|
order, err := s.paypal.CreateOrder(r.Context(), "credits:"+cid+":"+pkg.ID, "Neural Hunt hosted worker credits", centsValue(pkg.AmountCents), pkg.Currency, base+"/?paypal=return", base+"/?paypal=cancel")
|
|
if err != nil {
|
|
jsonOut(w, 502, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
approve := ApprovalURL(order)
|
|
if order.ID == "" || approve == "" {
|
|
jsonOut(w, 502, map[string]string{"error": "PayPal did not return an approval URL"})
|
|
return
|
|
}
|
|
if err := s.store.UpsertPayPalOrder(r.Context(), order.ID, cid, pkg.ID, pkg.AmountCents, pkg.Currency, pkg.CreditsMicros, order.Status); err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
jsonOut(w, 201, map[string]string{"order_id": order.ID, "approval_url": approve})
|
|
}
|
|
func (s *Service) paypalCapture(w http.ResponseWriter, r *http.Request) {
|
|
if !s.paypalAllowed() {
|
|
jsonOut(w, 503, map[string]string{"error": "PayPal disabled"})
|
|
return
|
|
}
|
|
var in struct {
|
|
OrderID string `json:"order_id"`
|
|
}
|
|
if decode(r, &in) != nil {
|
|
jsonOut(w, 400, map[string]string{"error": "bad json"})
|
|
return
|
|
}
|
|
stored, err := s.store.PayPalOrder(r.Context(), in.OrderID)
|
|
if err != nil || stored.CustomerID != customerID(r) {
|
|
jsonOut(w, 404, map[string]string{"error": "order not found"})
|
|
return
|
|
}
|
|
view, err := s.paypal.CaptureOrder(r.Context(), stored.OrderID)
|
|
if err != nil {
|
|
view, err = s.paypal.GetOrder(r.Context(), stored.OrderID)
|
|
}
|
|
if err != nil {
|
|
jsonOut(w, 502, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
if err := s.reconcilePayPal(r.Context(), stored, view); err != nil {
|
|
jsonOut(w, 409, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
bal, _ := s.store.BalanceMicros(r.Context(), stored.CustomerID)
|
|
jsonOut(w, 200, map[string]any{"ok": true, "balance_micros": bal})
|
|
}
|
|
func (s *Service) reconcilePayPal(ctx context.Context, stored PayPalOrder, view PayPalOrderView) error {
|
|
if !strings.EqualFold(view.Status, "COMPLETED") {
|
|
return fmt.Errorf("PayPal order status is %s", view.Status)
|
|
}
|
|
if len(view.PurchaseUnits) < 1 {
|
|
return errors.New("PayPal order has no purchase unit")
|
|
}
|
|
cents, err := parseMoneyCents(view.PurchaseUnits[0].Amount.Value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if cents != stored.AmountCents || !strings.EqualFold(view.PurchaseUnits[0].Amount.CurrencyCode, stored.Currency) {
|
|
return errors.New("PayPal captured amount/currency does not match requested credit package")
|
|
}
|
|
captureID := ""
|
|
for _, c := range view.PurchaseUnits[0].Payments.Captures {
|
|
if !strings.EqualFold(c.Status, "COMPLETED") {
|
|
continue
|
|
}
|
|
cc, err := parseMoneyCents(c.Amount.Value)
|
|
if err == nil && cc == stored.AmountCents && strings.EqualFold(c.Amount.CurrencyCode, stored.Currency) {
|
|
captureID = c.ID
|
|
break
|
|
}
|
|
}
|
|
if captureID == "" {
|
|
return errors.New("PayPal order has no matching completed capture")
|
|
}
|
|
return s.store.CompletePayPalOrder(ctx, stored.OrderID, captureID)
|
|
}
|
|
func findOrderID(v any) string {
|
|
switch x := v.(type) {
|
|
case map[string]any:
|
|
if sd, ok := x["supplementary_data"].(map[string]any); ok {
|
|
if rel, ok := sd["related_ids"].(map[string]any); ok {
|
|
if id, ok := rel["order_id"].(string); ok {
|
|
return id
|
|
}
|
|
}
|
|
}
|
|
for k, v := range x {
|
|
if k == "order_id" {
|
|
if id, ok := v.(string); ok {
|
|
return id
|
|
}
|
|
}
|
|
if id := findOrderID(v); id != "" {
|
|
return id
|
|
}
|
|
}
|
|
case []any:
|
|
for _, v := range x {
|
|
if id := findOrderID(v); id != "" {
|
|
return id
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
func (s *Service) paypalWebhook(w http.ResponseWriter, r *http.Request) {
|
|
if !s.paypalAllowed() {
|
|
jsonOut(w, 503, map[string]string{"error": "PayPal disabled"})
|
|
return
|
|
}
|
|
raw, err := io.ReadAll(io.LimitReader(r.Body, 2<<20))
|
|
if err != nil {
|
|
jsonOut(w, 400, map[string]string{"error": "bad body"})
|
|
return
|
|
}
|
|
ok, err := s.paypal.VerifyWebhook(r.Context(), s.cfg.PayPalWebhookID, r.Header, raw)
|
|
if err != nil || !ok {
|
|
jsonOut(w, 401, map[string]string{"error": "invalid PayPal webhook signature"})
|
|
return
|
|
}
|
|
var ev any
|
|
if json.Unmarshal(raw, &ev) != nil {
|
|
jsonOut(w, 400, map[string]string{"error": "bad event"})
|
|
return
|
|
}
|
|
orderID := findOrderID(ev)
|
|
if orderID != "" {
|
|
if stored, err := s.store.PayPalOrder(r.Context(), orderID); err == nil {
|
|
if view, err := s.paypal.GetOrder(r.Context(), orderID); err == nil {
|
|
_ = s.reconcilePayPal(r.Context(), stored, view)
|
|
}
|
|
}
|
|
}
|
|
jsonOut(w, 200, map[string]bool{"ok": true})
|
|
}
|
|
|
|
func (s *Service) adminLogin(w http.ResponseWriter, r *http.Request) {
|
|
var in struct{ Username, Password string }
|
|
if decode(r, &in) != nil {
|
|
jsonOut(w, 400, map[string]string{"error": "bad json"})
|
|
return
|
|
}
|
|
uok := len(in.Username) == len(s.cfg.AdminUser) && subtle.ConstantTimeCompare([]byte(in.Username), []byte(s.cfg.AdminUser)) == 1
|
|
pok := len(in.Password) == len(s.cfg.AdminPassword) && subtle.ConstantTimeCompare([]byte(in.Password), []byte(s.cfg.AdminPassword)) == 1
|
|
if !uok || !pok {
|
|
time.Sleep(300 * time.Millisecond)
|
|
jsonOut(w, 401, map[string]string{"error": "invalid credentials"})
|
|
return
|
|
}
|
|
sid := RandomToken(32)
|
|
s.adminSessions.Store(sid, time.Now().Add(12*time.Hour))
|
|
s.setCookie(w, csAdminCookie, sid, 12*time.Hour)
|
|
jsonOut(w, 200, map[string]bool{"ok": true})
|
|
}
|
|
func (s *Service) requireAdmin(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
c, err := r.Cookie(csAdminCookie)
|
|
if err != nil {
|
|
jsonOut(w, 401, map[string]string{"error": "unauthorized"})
|
|
return
|
|
}
|
|
v, ok := s.adminSessions.Load(c.Value)
|
|
exp, _ := v.(time.Time)
|
|
if !ok || time.Now().After(exp) {
|
|
s.adminSessions.Delete(c.Value)
|
|
jsonOut(w, 401, map[string]string{"error": "unauthorized"})
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
func (s *Service) adminLogout(w http.ResponseWriter, r *http.Request) {
|
|
if c, err := r.Cookie(csAdminCookie); err == nil {
|
|
s.adminSessions.Delete(c.Value)
|
|
}
|
|
s.clearCookie(w, csAdminCookie)
|
|
jsonOut(w, 200, map[string]bool{"ok": true})
|
|
}
|
|
func (s *Service) adminOverview(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := s.store.DB.QueryContext(r.Context(), `SELECT c.id,c.username,c.reward_client_id,c.created_at,COALESCE((SELECT sum(delta_micros) FROM credit_ledger l WHERE l.customer_id=c.id),0),(SELECT count(*) FROM workers w WHERE w.customer_id=c.id),(SELECT count(*) FROM workers w WHERE w.customer_id=c.id AND w.status='running') FROM customers c ORDER BY c.created_at DESC LIMIT 500`)
|
|
if err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
var out []map[string]any
|
|
for rows.Next() {
|
|
var id, user, reward string
|
|
var created, bal int64
|
|
var workers, running int
|
|
if err := rows.Scan(&id, &user, &reward, &created, &bal, &workers, &running); err != nil {
|
|
continue
|
|
}
|
|
out = append(out, map[string]any{"id": id, "username": user, "reward_client_id": reward, "created_at": time.UnixMilli(created).UTC(), "balance_micros": bal, "workers": workers, "running": running})
|
|
}
|
|
jsonOut(w, 200, map[string]any{"manual_credits_enabled": s.cfg.AllowManualCredits, "customers": out})
|
|
}
|
|
func (s *Service) adminCreditGrant(w http.ResponseWriter, r *http.Request) {
|
|
if !s.cfg.AllowManualCredits {
|
|
jsonOut(w, 403, map[string]string{"error": "manual credit bypass disabled; set CS_ALLOW_MANUAL_CREDITS=1 on the private admin service"})
|
|
return
|
|
}
|
|
var in struct {
|
|
CustomerID string `json:"customer_id"`
|
|
Credits float64 `json:"credits"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
if decode(r, &in) != nil || in.Credits <= 0 || in.Credits > 1_000_000 {
|
|
jsonOut(w, 400, map[string]string{"error": "invalid grant"})
|
|
return
|
|
}
|
|
micros := int64(math.Round(in.Credits * 1_000_000))
|
|
ref := "manual:" + RandomToken(12)
|
|
reason := "manual_test_grant"
|
|
if strings.TrimSpace(in.Reason) != "" {
|
|
reason += ":" + strings.TrimSpace(in.Reason)
|
|
}
|
|
if err := s.store.AddLedger(r.Context(), strings.TrimSpace(in.CustomerID), micros, reason, ref); err != nil {
|
|
jsonOut(w, 400, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
jsonOut(w, 200, map[string]any{"ok": true, "reference": ref})
|
|
}
|
|
|
|
// Keep sql imported in this file because several handlers intentionally treat
|
|
// sql.ErrNoRows as a 404 without leaking database details.
|
|
var _ = sql.ErrNoRows
|
|
var _ = url.PathEscape
|