release: promote development → master (v1.24.0) #280
@@ -229,6 +229,12 @@ The native Android app is a Capacitor 8 wrapper of the `ui` SPA, scaffolded unde
|
|||||||
|
|
||||||
- **Monetization** — «Фишка» currency, per-platform wallets, ads. Agreements in
|
- **Monetization** — «Фишка» currency, per-platform wallets, ads. Agreements in
|
||||||
`docs/PAYMENTS_DECISIONS_ru.md`; a phased plan (E0–E9). go-jet money rule above applies.
|
`docs/PAYMENTS_DECISIONS_ru.md`; a phased plan (E0–E9). go-jet money rule above applies.
|
||||||
|
- **VK payment title trap:** a product title with an HTML-special char (`& < > " '`) silently kills
|
||||||
|
VK purchases. VK HTML-escapes it in the `order_status_change` callback echo (`"`→`"`) but
|
||||||
|
signs a different form → the gateway logs `vk callback: bad signature` and rejects it (get_item
|
||||||
|
passes — its request carries no title). `validateProduct` now forbids those chars; keep titles on
|
||||||
|
« » guillemets. Diagnose a raw VK callback body by tcpdumping the plaintext caddy→gateway hop
|
||||||
|
(`tcp port 8081`, the h2c DATA frame is unencrypted); VK retries `order_status_change` for ~30 min.
|
||||||
- **PITR** — pgBackRest → Selectel S3 (encrypted, 30-day), currently **gated off**; runbook in
|
- **PITR** — pgBackRest → Selectel S3 (encrypted, 30-day), currently **gated off**; runbook in
|
||||||
`deploy/README.md`. Gotcha: run pgBackRest via docker-exec **as the `postgres` user** with
|
`deploy/README.md`. Gotcha: run pgBackRest via docker-exec **as the `postgres` user** with
|
||||||
`--pg1-user=scrabble`.
|
`--pg1-user=scrabble`.
|
||||||
|
|||||||
@@ -56,16 +56,25 @@ func validCurrency(c Currency) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateProduct checks a product's composition and prices. When sellable is true (the product is
|
// validateProduct checks a product's composition and prices. The title must be non-empty and free of
|
||||||
// or is becoming active) it also enforces the storefront shape: a pack (the chips atom ⇒ a money
|
// the HTML-special characters & < > " ' (they break VK's payment callback — see the inline note).
|
||||||
// price per method and no CHIP price, chips-only) or a value (no chips ⇒ a single CHIP price and no
|
// When sellable is true (the product is or is becoming active) it also enforces the storefront shape:
|
||||||
// money price). The tournament atom is never sellable (its economy is the tournament stage), so an
|
// a pack (the chips atom ⇒ a money price per method and no CHIP price, chips-only) or a value (no
|
||||||
// active product may not carry it; an archived draft may (a template for later) and skips the shape
|
// chips ⇒ a single CHIP price and no money price). The tournament atom is never sellable (its economy
|
||||||
// check.
|
// 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 {
|
func validateProduct(in ProductInput, sellable bool) error {
|
||||||
if strings.TrimSpace(in.Title) == "" {
|
if strings.TrimSpace(in.Title) == "" {
|
||||||
return errors.New("product title is required")
|
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. `"` -> `"`) 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 {
|
if len(in.Atoms) == 0 {
|
||||||
return errors.New("product needs at least one atom")
|
return errors.New("product needs at least one atom")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ func TestValidateProduct(t *testing.T) {
|
|||||||
{"money price without a method", ProductInput{Title: "x", Atoms: pack.Atoms, Prices: []PriceLine{{Method: "", Currency: CurrencyRUB, Amount: 149}}}, true, true},
|
{"money price without a method", ProductInput{Title: "x", Atoms: pack.Atoms, Prices: []PriceLine{{Method: "", Currency: CurrencyRUB, Amount: 149}}}, true, true},
|
||||||
{"duplicate price", ProductInput{Title: "x", Atoms: pack.Atoms, Prices: []PriceLine{{Method: "direct", Currency: CurrencyRUB, Amount: 1}, {Method: "direct", Currency: CurrencyRUB, Amount: 2}}}, true, true},
|
{"duplicate price", ProductInput{Title: "x", Atoms: pack.Atoms, Prices: []PriceLine{{Method: "direct", Currency: CurrencyRUB, Amount: 1}, {Method: "direct", Currency: CurrencyRUB, Amount: 2}}}, true, true},
|
||||||
{"empty title", ProductInput{Title: " ", Atoms: value.Atoms, Prices: value.Prices}, true, true},
|
{"empty title", ProductInput{Title: " ", Atoms: value.Atoms, Prices: value.Prices}, true, true},
|
||||||
|
{"title with a double quote", ProductInput{Title: `50 "фишек"`, Atoms: value.Atoms, Prices: value.Prices}, true, true},
|
||||||
|
{"title with an ampersand", ProductInput{Title: "фишки & подсказки", Atoms: value.Atoms, Prices: value.Prices}, true, true},
|
||||||
|
{"title with an angle bracket", ProductInput{Title: "50 <фишек>", Atoms: value.Atoms, Prices: value.Prices}, true, true},
|
||||||
|
{"title with an apostrophe", ProductInput{Title: "player's pack", Atoms: value.Atoms, Prices: value.Prices}, true, true},
|
||||||
|
{"title with a special char is refused even for a draft", ProductInput{Title: `x "y"`, Atoms: value.Atoms}, false, true},
|
||||||
|
{"title with guillemets is allowed", ProductInput{Title: "50 «фишек»", Atoms: value.Atoms, Prices: value.Prices}, true, false},
|
||||||
{"no atoms", ProductInput{Title: "x", Prices: value.Prices}, true, true},
|
{"no atoms", ProductInput{Title: "x", Prices: value.Prices}, true, true},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -192,6 +192,13 @@ live in the database and are edited in the admin console, no release required.
|
|||||||
purchase stores a **snapshot** of what was sold (atom composition + price at the time) into
|
purchase stores a **snapshot** of what was sold (atom composition + price at the time) into
|
||||||
an archive, so history/receipts/tax are independent of later catalog edits.
|
an archive, so history/receipts/tax are independent of later catalog edits.
|
||||||
|
|
||||||
|
**Titles reject HTML-special characters.** The admin editor refuses a product title containing
|
||||||
|
`& < > " '` (`validateProduct`). VK HTML-escapes such characters in its `order_status_change`
|
||||||
|
payment-callback echo (e.g. `"` → `"`) but computes the notification signature over a different
|
||||||
|
form, so our callback signature check no longer matches and the purchase silently fails to confirm —
|
||||||
|
while the `get_item` phase (whose request carries no title) still passes. Use « » guillemets, not
|
||||||
|
straight quotes.
|
||||||
|
|
||||||
## 8. Trusted platform signal
|
## 8. Trusted platform signal
|
||||||
|
|
||||||
The gate (§4) needs a **trusted, unforgeable** platform context on the server. The client is
|
The gate (§4) needs a **trusted, unforgeable** platform context on the server. The client is
|
||||||
|
|||||||
Reference in New Issue
Block a user