Files
scrabble-game/backend/internal/payments/catalog_admin.go
T
Ilia Denisov f60327c1ce
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m52s
fix(payments): reject HTML-special chars in product titles (VK payment guard)
A product title containing & < > " ' silently breaks VK purchases: VK
HTML-escapes the character in its order_status_change payment-callback echo
(e.g. " -> &quot;) but signs the notification over a different form, so the
gateway's signature check no longer matches ("vk callback: bad signature")
and the purchase never confirms — while get_item (whose request carries no
title) still passes.

validateProduct now rejects those characters at the admin catalog editor —
the single write path for CreateProduct/UpdateProduct/SetProductActive — so a
title can't reintroduce the break. Reject, not transform: the operator retypes
a clean title (« » guillemets, not straight quotes), so the stored value stays
identical everywhere (storefront, wallet, VK box).

Docs: docs/PAYMENTS.md §7 + agent field notes; Go Doc on validateProduct.
Test: validateProduct HTML-special-char cases. No wire/schema change.
2026-07-16 20:57:04 +02:00

269 lines
9.0 KiB
Go

package payments
import (
"context"
"errors"
"fmt"
"math"
"slices"
"strings"
"github.com/google/uuid"
)
// AdminProduct is one catalog product for the admin editor: its full composition, every price, the
// archived flag (Active), and whether it has ever been transacted — which forbids a hard delete
// (orders / ledger rows reference it, and the ledger is append-only).
type AdminProduct struct {
ID uuid.UUID
Title string
Active bool
Atoms []AtomLine
Prices []PriceLine
Transacted bool
}
// AtomLine is one atom of a product: the atom type and its positive quantity.
type AtomLine struct {
Atom string
Quantity int
}
// PriceLine is one price of a product: the payment method ("" for a value's CHIP price, which is
// stored with a NULL method), the currency, and the amount in that currency's minor units.
type PriceLine struct {
Method string
Currency Currency
Amount int64
}
// ProductInput is the editable content of a product: its title, atom composition and prices.
type ProductInput struct {
Title string
Atoms []AtomLine
Prices []PriceLine
}
// knownAtoms is the fixed atom set, mirroring the catalog_atom seed / CHECK.
var knownAtoms = map[string]bool{atomChips: true, "hints": true, "noads_days": true, "tournament": true}
// validCurrency reports whether c is one of the four catalog currencies.
func validCurrency(c Currency) bool {
switch c {
case CurrencyRUB, CurrencyVote, CurrencyStar, CurrencyChip:
return true
}
return false
}
// validateProduct checks a product's composition and prices. The title must be non-empty and free of
// the HTML-special characters & < > " ' (they break VK's payment callback — see the inline note).
// When sellable is true (the product is or is becoming active) it also enforces the storefront shape:
// a pack (the chips atom ⇒ a money price per method and no CHIP price, chips-only) or a value (no
// chips ⇒ a single CHIP price and no money price). The tournament atom is never sellable (its economy
// is the tournament stage), so an active product may not carry it; an archived draft may (a template
// for later) and skips the shape check.
func validateProduct(in ProductInput, sellable bool) error {
if strings.TrimSpace(in.Title) == "" {
return errors.New("product title is required")
}
// Reject the HTML-special characters in a title. VK HTML-escapes them in its order_status_change
// payment echo (e.g. `"` -> `&quot;`) but computes the notification signature over a different
// form, so our callback signature check no longer matches and the purchase cannot be confirmed.
// Titles never need them — use « » guillemets, not straight quotes. The rule holds for drafts too,
// since a draft is sold later.
if i := strings.IndexAny(in.Title, "&<>\"'"); i >= 0 {
return fmt.Errorf("product title must not contain %q — use « » guillemets, not straight quotes (VK payment callbacks break on it)", in.Title[i:i+1])
}
if len(in.Atoms) == 0 {
return errors.New("product needs at least one atom")
}
seen := map[string]bool{}
hasChips, hasTournament, hasBenefit := false, false, false
for _, a := range in.Atoms {
if !knownAtoms[a.Atom] {
return fmt.Errorf("unknown atom %q", a.Atom)
}
if seen[a.Atom] {
return fmt.Errorf("duplicate atom %q", a.Atom)
}
seen[a.Atom] = true
if a.Quantity <= 0 {
return fmt.Errorf("atom %q quantity must be positive", a.Atom)
}
switch a.Atom {
case atomChips:
hasChips = true
case "tournament":
hasTournament = true
default:
hasBenefit = true
}
}
priceSeen := map[string]bool{}
hasChipPrice, hasMoneyPrice := false, false
for _, p := range in.Prices {
if p.Method != "" && p.Method != string(SourceVK) && p.Method != string(SourceTelegram) && p.Method != string(SourceDirect) {
return fmt.Errorf("invalid price method %q", p.Method)
}
if !validCurrency(p.Currency) {
return fmt.Errorf("invalid currency %q", p.Currency)
}
if p.Amount < 0 {
return errors.New("price amount must be non-negative")
}
if p.Currency == CurrencyChip && p.Method != "" {
return errors.New("a CHIP price must have no method")
}
if p.Currency != CurrencyChip && p.Method == "" {
return fmt.Errorf("a %s price needs a payment method", p.Currency)
}
key := p.Method + "|" + string(p.Currency)
if priceSeen[key] {
return fmt.Errorf("duplicate price (%s, %s)", p.Method, p.Currency)
}
priceSeen[key] = true
if p.Currency == CurrencyChip {
hasChipPrice = true
} else {
hasMoneyPrice = true
}
}
if !sellable {
return nil // an archived draft may be incomplete
}
if hasTournament {
return errors.New("a product carrying the tournament atom cannot be sold yet")
}
if hasChips {
if hasBenefit {
return errors.New("a chip pack must contain only the chips atom")
}
if !hasMoneyPrice || hasChipPrice {
return errors.New("a chip pack needs a money price per method and no CHIP price")
}
} else {
if !hasChipPrice || hasMoneyPrice {
return errors.New("a value needs a single CHIP price and no money price")
}
}
return nil
}
// AdminCatalog lists products with their composition, prices and whether each has been transacted,
// for the admin editor. includeInactive adds the archived products to the active ones (the console's
// active/all toggle); with it false only active products are returned. Read uncached, straight from
// the catalog tables.
func (s *Service) AdminCatalog(ctx context.Context, includeInactive bool) ([]AdminProduct, error) {
return s.store.adminCatalog(ctx, includeInactive)
}
// SortAdminCatalog orders an admin catalog list in place the same way the public offer lists products
// ([projectOfferPricing]): chip packs first, ascending by rouble price; then chip-priced values,
// grouped (hints only, no-ads only, no-ads + hints, tournament) and ascending by chip price within a
// group. It is stable, so equal-keyed products keep their incoming order, and it keys archived
// products the same as active ones (the admin list shows both).
func SortAdminCatalog(products []AdminProduct) {
slices.SortStableFunc(products, func(a, b AdminProduct) int {
return compareCatalogRank(adminRank(a), adminRank(b))
})
}
// adminIsPack reports whether the product is a chip pack (it carries the chips atom).
func adminIsPack(p AdminProduct) bool {
for _, a := range p.Atoms {
if a.Atom == atomChips {
return true
}
}
return false
}
// adminValueGroup ranks a chip-priced value into the shared listing groups (see [valueGroup]).
func adminValueGroup(p AdminProduct) int {
hasHints, hasNoAds, hasTournament := false, false, false
for _, a := range p.Atoms {
switch a.Atom {
case "hints":
hasHints = true
case "noads_days":
hasNoAds = true
case "tournament":
hasTournament = true
}
}
return valueGroup(hasHints, hasNoAds, hasTournament)
}
// adminPriceAmount returns the minor-unit amount of the product's price for the method and currency,
// or math.MaxInt64 when it carries no such price (so a misconfigured product sorts last, not first).
func adminPriceAmount(p AdminProduct, method string, cur Currency) int64 {
for _, pr := range p.Prices {
if pr.Method == method && pr.Currency == cur {
return pr.Amount
}
}
return math.MaxInt64
}
// CreateProduct validates and inserts a new product with its atoms and prices, returning its id. A
// product created active must satisfy the sellable shape.
func (s *Service) CreateProduct(ctx context.Context, in ProductInput, active bool) (uuid.UUID, error) {
if err := validateProduct(in, active); err != nil {
return uuid.Nil, err
}
id, err := s.store.createProduct(ctx, in, active, s.clock())
if err == nil {
s.markOfferStale()
}
return id, err
}
// UpdateProduct validates and replaces a product's title, atoms and prices. An active product must
// satisfy the sellable shape.
func (s *Service) UpdateProduct(ctx context.Context, id uuid.UUID, in ProductInput) (err error) {
active, err := s.store.productActive(ctx, id)
if err != nil {
return err
}
if err := validateProduct(in, active); err != nil {
return err
}
if err := s.store.updateProduct(ctx, id, in, s.clock()); err != nil {
return err
}
s.markOfferStale()
return nil
}
// SetProductActive archives (active=false) or unarchives a product. Unarchiving revalidates the
// sellable shape, so a draft or a tournament product cannot be put on sale.
func (s *Service) SetProductActive(ctx context.Context, id uuid.UUID, active bool) error {
if active {
in, err := s.store.productInput(ctx, id)
if err != nil {
return err
}
if err := validateProduct(in, true); err != nil {
return err
}
}
if err := s.store.setProductActive(ctx, id, active, s.clock()); err != nil {
return err
}
s.markOfferStale()
return nil
}
// DeleteProduct hard-deletes a product only when it has never been transacted (no order or ledger
// row references it); otherwise it returns ErrProductTransacted and the caller archives instead.
func (s *Service) DeleteProduct(ctx context.Context, id uuid.UUID) error {
if err := s.store.deleteProduct(ctx, id); err != nil {
return err
}
s.markOfferStale()
return nil
}