package main import ( "bytes" "encoding/json" "fmt" "io" "log" "math/rand" "net/http" "strconv" "time" messages "git.tornberg.me/go-cart-actor/proto" ) type PoolServer struct { pod_name string pool GrainPool } func NewPoolServer(pool GrainPool, pod_name string) *PoolServer { return &PoolServer{ pod_name: pod_name, pool: pool, } } func (s *PoolServer) process(id CartId, mutation interface{}) (*CartGrain, error) { grain, err := s.pool.Apply(id, mutation) if err != nil { return nil, err } return grain, nil } func (s *PoolServer) HandleGet(w http.ResponseWriter, r *http.Request, id CartId) error { grain, err := s.pool.Get(id) if err != nil { return err } return s.WriteResult(w, grain) } func (s *PoolServer) HandleAddSku(w http.ResponseWriter, r *http.Request, id CartId) error { sku := r.PathValue("sku") data, err := s.process(id, &messages.AddRequest{Sku: sku, Quantity: 1}) if err != nil { return err } return s.WriteResult(w, data) } func ErrorHandler(fn func(w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { err := fn(w, r) if err != nil { log.Printf("Server error, not remote error: %v\n", err) w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) } } } func (s *PoolServer) WriteResult(w http.ResponseWriter, result *CartGrain) error { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("X-Pod-Name", s.pod_name) if result == nil { w.WriteHeader(http.StatusInternalServerError) return nil } w.WriteHeader(http.StatusOK) enc := json.NewEncoder(w) err := enc.Encode(result) return err } func (s *PoolServer) HandleDeleteItem(w http.ResponseWriter, r *http.Request, id CartId) error { itemIdString := r.PathValue("itemId") itemId, err := strconv.Atoi(itemIdString) if err != nil { return err } data, err := s.process(id, &messages.RemoveItem{Id: int64(itemId)}) if err != nil { return err } return s.WriteResult(w, data) } type SetDelivery struct { Provider string `json:"provider"` Items []int64 `json:"items"` PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"` } func (s *PoolServer) HandleSetDelivery(w http.ResponseWriter, r *http.Request, id CartId) error { delivery := SetDelivery{} err := json.NewDecoder(r.Body).Decode(&delivery) if err != nil { return err } data, err := s.process(id, &messages.SetDelivery{ Provider: delivery.Provider, Items: delivery.Items, PickupPoint: delivery.PickupPoint, }) if err != nil { return err } return s.WriteResult(w, data) } func (s *PoolServer) HandleSetPickupPoint(w http.ResponseWriter, r *http.Request, id CartId) error { deliveryIdString := r.PathValue("deliveryId") deliveryId, err := strconv.Atoi(deliveryIdString) if err != nil { return err } pickupPoint := messages.PickupPoint{} err = json.NewDecoder(r.Body).Decode(&pickupPoint) if err != nil { return err } reply, err := s.process(id, &messages.SetPickupPoint{ DeliveryId: int64(deliveryId), Id: pickupPoint.Id, Name: pickupPoint.Name, Address: pickupPoint.Address, City: pickupPoint.City, Zip: pickupPoint.Zip, Country: pickupPoint.Country, }) if err != nil { return err } return s.WriteResult(w, reply) } func (s *PoolServer) HandleRemoveDelivery(w http.ResponseWriter, r *http.Request, id CartId) error { deliveryIdString := r.PathValue("deliveryId") deliveryId, err := strconv.Atoi(deliveryIdString) if err != nil { return err } reply, err := s.process(id, &messages.RemoveDelivery{Id: int64(deliveryId)}) if err != nil { return err } return s.WriteResult(w, reply) } func (s *PoolServer) HandleQuantityChange(w http.ResponseWriter, r *http.Request, id CartId) error { changeQuantity := messages.ChangeQuantity{} err := json.NewDecoder(r.Body).Decode(&changeQuantity) if err != nil { return err } reply, err := s.process(id, &changeQuantity) if err != nil { return err } return s.WriteResult(w, reply) } func (s *PoolServer) HandleSetCartItems(w http.ResponseWriter, r *http.Request, id CartId) error { setCartItems := messages.SetCartRequest{} err := json.NewDecoder(r.Body).Decode(&setCartItems) if err != nil { return err } reply, err := s.process(id, &setCartItems) if err != nil { return err } return s.WriteResult(w, reply) } func (s *PoolServer) HandleAddRequest(w http.ResponseWriter, r *http.Request, id CartId) error { addRequest := messages.AddRequest{} err := json.NewDecoder(r.Body).Decode(&addRequest) if err != nil { return err } reply, err := s.process(id, &addRequest) if err != nil { return err } return s.WriteResult(w, reply) } func (s *PoolServer) HandleConfirmation(w http.ResponseWriter, r *http.Request, id CartId) error { orderId := r.PathValue("orderId") if orderId == "" { return fmt.Errorf("orderId is empty") } order, err := KlarnaInstance.GetOrder(orderId) if err != nil { return err } w.Header().Set("Content-Type", "application/json") w.Header().Set("X-Pod-Name", s.pod_name) w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Access-Control-Allow-Origin", "*") w.WriteHeader(http.StatusOK) return json.NewEncoder(w).Encode(order) } func (s *PoolServer) CreateOrUpdateCheckout(host string, id CartId) (*CheckoutOrder, error) { meta := &CheckoutMeta{ Terms: fmt.Sprintf("https://%s/terms", host), Checkout: fmt.Sprintf("https://%s/checkout?order_id={checkout.order.id}", host), Confirmation: fmt.Sprintf("https://%s/confirmation/{checkout.order.id}", host), Validation: fmt.Sprintf("https://%s/validate", host), Push: fmt.Sprintf("https://%s/push?order_id={checkout.order.id}", host), Country: getCountryFromHost(host), } // Get current grain state (may be local or remote) grain, err := s.pool.Get(id) if err != nil { return nil, err } // Build pure checkout payload payload, _, err := BuildCheckoutOrderPayload(grain, meta) if err != nil { return nil, err } if grain.OrderReference != "" { return KlarnaInstance.UpdateOrder(grain.OrderReference, bytes.NewReader(payload)) } else { return KlarnaInstance.CreateOrder(bytes.NewReader(payload)) } } func (s *PoolServer) ApplyCheckoutStarted(klarnaOrder *CheckoutOrder, id CartId) (*CartGrain, error) { // Persist initialization state via mutation (best-effort) return s.pool.Apply(id, &messages.InitializeCheckout{ OrderId: klarnaOrder.ID, Status: klarnaOrder.Status, PaymentInProgress: true, }) } func (s *PoolServer) HandleCheckout(w http.ResponseWriter, r *http.Request, id CartId) error { klarnaOrder, err := s.CreateOrUpdateCheckout(r.Host, id) if err != nil { return err } s.ApplyCheckoutStarted(klarnaOrder, id) w.Header().Set("Content-Type", "application/json") return json.NewEncoder(w).Encode(klarnaOrder) } func NewCartId() CartId { // Deprecated: legacy random/time based cart id generator. // Retained for compatibility; new code should prefer canonical CartID path. cid, err := NewCartID() if err != nil { // Fallback to legacy method only if crypto/rand fails id := time.Now().UnixNano() + rand.Int63() return ToCartId(fmt.Sprintf("%d", id)) } return CartIDToLegacy(cid) } func CookieCartIdHandler(fn func(w http.ResponseWriter, r *http.Request, cartId CartId) error) func(w http.ResponseWriter, r *http.Request) error { return func(w http.ResponseWriter, r *http.Request) error { var legacy CartId cookies := r.CookiesNamed("cartid") if len(cookies) == 0 { cid, generated, _, err := CanonicalizeOrLegacy("") if err != nil { return fmt.Errorf("failed to generate cart id: %w", err) } legacy = CartIDToLegacy(cid) if generated { http.SetCookie(w, &http.Cookie{ Name: "cartid", Value: cid.String(), Secure: r.TLS != nil, HttpOnly: true, Path: "/", Expires: time.Now().AddDate(0, 0, 14), SameSite: http.SameSiteLaxMode, }) w.Header().Set("Set-Cart-Id", cid.String()) } } else { raw := cookies[0].Value cid, generated, wasBase62, err := CanonicalizeOrLegacy(raw) if err != nil { return fmt.Errorf("failed to canonicalize cart id: %w", err) } legacy = CartIDToLegacy(cid) if generated && wasBase62 { http.SetCookie(w, &http.Cookie{ Name: "cartid", Value: cid.String(), Secure: r.TLS != nil, HttpOnly: true, Path: "/", Expires: time.Now().AddDate(0, 0, 14), SameSite: http.SameSiteLaxMode, }) w.Header().Set("Set-Cart-Id", cid.String()) } } // Ownership proxy AFTER id extraction (cookie mode) if ownershipProxyAfterExtraction != nil { if handled, err := ownershipProxyAfterExtraction(legacy, w, r); handled || err != nil { return err } } return fn(w, r, legacy) } } func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, cartId CartId) error { cartId = NewCartId() http.SetCookie(w, &http.Cookie{ Name: "cartid", Value: cartId.String(), Path: "/", Secure: r.TLS != nil, HttpOnly: true, Expires: time.Unix(0, 0), SameSite: http.SameSiteLaxMode, }) w.WriteHeader(http.StatusOK) return nil } func CartIdHandler(fn func(w http.ResponseWriter, r *http.Request, cartId CartId) error) func(w http.ResponseWriter, r *http.Request) error { return func(w http.ResponseWriter, r *http.Request) error { raw := r.PathValue("id") cid, generated, wasBase62, err := CanonicalizeOrLegacy(raw) if err != nil { return fmt.Errorf("invalid cart id: %w", err) } legacy := CartIDToLegacy(cid) if generated && wasBase62 { w.Header().Set("Set-Cart-Id", cid.String()) } // Ownership proxy AFTER path id extraction (explicit id mode) if ownershipProxyAfterExtraction != nil { if handled, err := ownershipProxyAfterExtraction(legacy, w, r); handled || err != nil { return err } } return fn(w, r, legacy) } } var ownershipProxyAfterExtraction func(cartId CartId, w http.ResponseWriter, r *http.Request) (handled bool, err error) func (s *PoolServer) Serve() *http.ServeMux { // Install ownership proxy hook that runs AFTER id extraction (cookie OR path) ownershipProxyAfterExtraction = func(cartId CartId, w http.ResponseWriter, r *http.Request) (bool, error) { if cartId.String() == "" { return false, nil } owner := s.pool.OwnerHost(cartId) if owner == "" || owner == s.pool.Hostname() { // Set / refresh cartowner cookie pointing to the local host (claim or already owned). localHost := owner if localHost == "" { localHost = s.pool.Hostname() } http.SetCookie(w, &http.Cookie{ Name: "cartowner", Value: localHost, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, }) return false, nil } // For remote ownership set cartowner cookie to remote host for sticky sessions. http.SetCookie(w, &http.Cookie{ Name: "cartowner", Value: owner, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, }) // Proxy logic (simplified): reuse existing request to owning host on same port. target := "http://" + owner + r.URL.Path if q := r.URL.RawQuery; q != "" { target += "?" + q } req, err := http.NewRequestWithContext(r.Context(), r.Method, target, r.Body) if err != nil { http.Error(w, "proxy build error", http.StatusBadGateway) return true, err } for k, v := range r.Header { for _, vv := range v { req.Header.Add(k, vv) } } req.Header.Set("X-Forwarded-Host", r.Host) req.Header.Set("X-Cart-Id", cartId.String()) req.Header.Set("X-Cart-Owner", owner) resp, err := http.DefaultClient.Do(req) if err != nil { http.Error(w, "proxy upstream error", http.StatusBadGateway) return true, err } defer resp.Body.Close() for k, v := range resp.Header { for _, vv := range v { w.Header().Add(k, vv) } } w.Header().Set("X-Cart-Owner-Routed", "true") w.WriteHeader(resp.StatusCode) _, copyErr := io.Copy(w, resp.Body) if copyErr != nil { return true, copyErr } return true, nil } mux := http.NewServeMux() mux.HandleFunc("OPTIONS /", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") w.WriteHeader(http.StatusOK) }) mux.HandleFunc("GET /", ErrorHandler(CookieCartIdHandler(s.HandleGet))) mux.HandleFunc("GET /add/{sku}", ErrorHandler(CookieCartIdHandler(s.HandleAddSku))) mux.HandleFunc("POST /", ErrorHandler(CookieCartIdHandler(s.HandleAddRequest))) mux.HandleFunc("POST /set", ErrorHandler(CookieCartIdHandler(s.HandleSetCartItems))) mux.HandleFunc("DELETE /{itemId}", ErrorHandler(CookieCartIdHandler(s.HandleDeleteItem))) mux.HandleFunc("PUT /", ErrorHandler(CookieCartIdHandler(s.HandleQuantityChange))) mux.HandleFunc("DELETE /", ErrorHandler(CookieCartIdHandler(s.RemoveCartCookie))) mux.HandleFunc("POST /delivery", ErrorHandler(CookieCartIdHandler(s.HandleSetDelivery))) mux.HandleFunc("DELETE /delivery/{deliveryId}", ErrorHandler(CookieCartIdHandler(s.HandleRemoveDelivery))) mux.HandleFunc("PUT /delivery/{deliveryId}/pickupPoint", ErrorHandler(CookieCartIdHandler(s.HandleSetPickupPoint))) mux.HandleFunc("GET /checkout", ErrorHandler(CookieCartIdHandler(s.HandleCheckout))) mux.HandleFunc("GET /confirmation/{orderId}", ErrorHandler(CookieCartIdHandler(s.HandleConfirmation))) mux.HandleFunc("GET /byid/{id}", ErrorHandler(CartIdHandler(s.HandleGet))) mux.HandleFunc("GET /byid/{id}/add/{sku}", ErrorHandler(CartIdHandler(s.HandleAddSku))) mux.HandleFunc("POST /byid/{id}", ErrorHandler(CartIdHandler(s.HandleAddRequest))) mux.HandleFunc("DELETE /byid/{id}/{itemId}", ErrorHandler(CartIdHandler(s.HandleDeleteItem))) mux.HandleFunc("PUT /byid/{id}", ErrorHandler(CartIdHandler(s.HandleQuantityChange))) mux.HandleFunc("POST /byid/{id}/delivery", ErrorHandler(CartIdHandler(s.HandleSetDelivery))) mux.HandleFunc("DELETE /byid/{id}/delivery/{deliveryId}", ErrorHandler(CartIdHandler(s.HandleRemoveDelivery))) mux.HandleFunc("PUT /byid/{id}/delivery/{deliveryId}/pickupPoint", ErrorHandler(CartIdHandler(s.HandleSetPickupPoint))) mux.HandleFunc("GET /byid/{id}/checkout", ErrorHandler(CartIdHandler(s.HandleCheckout))) mux.HandleFunc("GET /byid/{id}/confirmation", ErrorHandler(CartIdHandler(s.HandleConfirmation))) return mux }