Compare commits
37 Commits
ed79c30d2a
...
v1.20.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 39e03d22e4 | |||
| d8d1b06eee | |||
| c16008e67a | |||
| ff55d5de83 | |||
| aaf162de40 | |||
| 7cc2b50d23 | |||
| eb7fa98426 | |||
| 81d5383c42 | |||
| b6af682381 | |||
| 5ce9f7e9b4 | |||
| 3456a0b3ff | |||
| a41281c495 | |||
| 516ffbe5f0 | |||
| 6badc20078 | |||
| 0ca01133b5 | |||
| 45f0b34881 | |||
| 18785efc8c | |||
| 780ff68ec2 | |||
| 57ff2d03f8 | |||
| a9d0986e74 | |||
| 45957bdcd6 | |||
| 829e29a726 | |||
| 399508f2f0 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
@@ -21,7 +21,17 @@
|
|||||||
<div><button type="submit">Add</button></div>
|
<div><button type="submit">Add</button></div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="panel"><h2>Rewarded ads (watch for chips)</h2>
|
||||||
|
<p class="note">The chips a player earns per rewarded-video view (VK only), plus the per-day and per-hour caps that bound free chips — <strong>0 payout turns rewarded off</strong>. This is the shared reward config, not a product.</p>
|
||||||
|
<form class="form col" method="post" action="/_gm/catalog/reward">
|
||||||
|
<label>Chips per view <input type="number" name="reward_payout" min="0" value="{{.RewardPayout}}"></label>
|
||||||
|
<label>Daily cap <input type="number" name="reward_daily" min="0" value="{{.RewardDailyCap}}"></label>
|
||||||
|
<label>Hourly cap <input type="number" name="reward_hourly" min="0" value="{{.RewardHourlyCap}}"></label>
|
||||||
|
<div><button type="submit">Save</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
<section class="panel"><h2>Products</h2>
|
<section class="panel"><h2>Products</h2>
|
||||||
|
<p class="note">Showing {{if .ShowAll}}<strong>all</strong> products (active + archived) — <a href="/_gm/catalog">active only</a>{{else}}<strong>active</strong> products — <a href="/_gm/catalog?all=1">show all (incl. archived)</a>{{end}}.</p>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th>Title</th><th>Status</th><th>Atoms</th><th>Prices</th><th></th></tr></thead>
|
<thead><tr><th>Title</th><th>Status</th><th>Atoms</th><th>Prices</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -677,6 +677,15 @@ type FeedbackDetailView struct {
|
|||||||
// CatalogView is the product-catalog list page.
|
// CatalogView is the product-catalog list page.
|
||||||
type CatalogView struct {
|
type CatalogView struct {
|
||||||
Products []ProductRow
|
Products []ProductRow
|
||||||
|
// ShowAll reports whether archived products are listed alongside active ones (the active/all
|
||||||
|
// toggle); it drives the toggle link and the list heading.
|
||||||
|
ShowAll bool
|
||||||
|
// RewardPayout / RewardDailyCap / RewardHourlyCap are the rewarded-video config (chips earned per
|
||||||
|
// view and the per-day / per-hour anti-abuse caps), shown in and edited from the page's
|
||||||
|
// rewarded-ads form. A 0 payout means rewarded is inert.
|
||||||
|
RewardPayout int
|
||||||
|
RewardDailyCap int
|
||||||
|
RewardHourlyCap int
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProductRow is one product in the catalog list: its composition, prices, the archived flag
|
// ProductRow is one product in the catalog list: its composition, prices, the archived flag
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
// productByTitle finds a catalog product by its (unique in the test) title.
|
// productByTitle finds a catalog product by its (unique in the test) title.
|
||||||
func productByTitle(t *testing.T, pay *payments.Service, title string) payments.AdminProduct {
|
func productByTitle(t *testing.T, pay *payments.Service, title string) payments.AdminProduct {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
all, err := pay.AdminCatalog(context.Background())
|
all, err := pay.AdminCatalog(context.Background(), true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("admin catalog: %v", err)
|
t.Fatalf("admin catalog: %v", err)
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ func TestConsoleCatalogEditor(t *testing.T) {
|
|||||||
if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-bad&chips=100&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Invalid product") {
|
if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-bad&chips=100&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Invalid product") {
|
||||||
t.Fatalf("bad pack = %d, has 'Invalid product' = %v", code, strings.Contains(body, "Invalid product"))
|
t.Fatalf("bad pack = %d, has 'Invalid product' = %v", code, strings.Contains(body, "Invalid product"))
|
||||||
}
|
}
|
||||||
if _, err := pay.AdminCatalog(ctx); err != nil {
|
if _, err := pay.AdminCatalog(ctx, true); err != nil {
|
||||||
t.Fatalf("catalog: %v", err)
|
t.Fatalf("catalog: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,3 +95,59 @@ func TestConsoleCatalogEditor(t *testing.T) {
|
|||||||
t.Fatalf("delete transacted = %d, has 'Cannot delete' = %v", code, strings.Contains(body, "Cannot delete"))
|
t.Fatalf("delete transacted = %d, has 'Cannot delete' = %v", code, strings.Contains(body, "Cannot delete"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConsoleCatalogActiveToggle checks the catalog console lists only active products by default and
|
||||||
|
// includes the archived ones under ?all=1 (the active/all toggle).
|
||||||
|
func TestConsoleCatalogActiveToggle(t *testing.T) {
|
||||||
|
srv, _, _ := bannerServer(t)
|
||||||
|
h := srv.Handler()
|
||||||
|
const origin = "http://admin.test"
|
||||||
|
const catalog = "http://admin.test/_gm/catalog"
|
||||||
|
|
||||||
|
// An active value and an archived one (created without the active flag).
|
||||||
|
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=toggle-active&hints=5&price_chip=50&active=true", origin); code != http.StatusOK {
|
||||||
|
t.Fatal("create active failed")
|
||||||
|
}
|
||||||
|
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=toggle-archived&hints=9&price_chip=90", origin); code != http.StatusOK {
|
||||||
|
t.Fatal("create archived failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default view: active only.
|
||||||
|
if _, body := consoleDo(h, http.MethodGet, catalog, "", ""); !strings.Contains(body, "toggle-active") || strings.Contains(body, "toggle-archived") {
|
||||||
|
t.Errorf("default view: active listed=%v, archived listed=%v (want true/false)",
|
||||||
|
strings.Contains(body, "toggle-active"), strings.Contains(body, "toggle-archived"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ?all=1: active and archived.
|
||||||
|
if _, body := consoleDo(h, http.MethodGet, catalog+"?all=1", "", ""); !strings.Contains(body, "toggle-active") || !strings.Contains(body, "toggle-archived") {
|
||||||
|
t.Errorf("all view: active listed=%v, archived listed=%v (want true/true)",
|
||||||
|
strings.Contains(body, "toggle-active"), strings.Contains(body, "toggle-archived"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConsoleRewardConfig checks the rewarded-ads config form on the catalog page: a POST updates the
|
||||||
|
// payout and caps, the page pre-fills the current values, and a negative value is refused.
|
||||||
|
func TestConsoleRewardConfig(t *testing.T) {
|
||||||
|
srv, _, pay := bannerServer(t)
|
||||||
|
h := srv.Handler()
|
||||||
|
const origin = "http://admin.test"
|
||||||
|
const reward = "http://admin.test/_gm/catalog/reward"
|
||||||
|
|
||||||
|
// Set the payout and caps.
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, reward, "reward_payout=7&reward_daily=40&reward_hourly=9", origin); code != http.StatusOK || !strings.Contains(body, "Saved") {
|
||||||
|
t.Fatalf("set reward = %d, has 'Saved' = %v", code, strings.Contains(body, "Saved"))
|
||||||
|
}
|
||||||
|
if payout, daily, hourly, err := pay.RewardConfig(context.Background()); err != nil || payout != 7 || daily != 40 || hourly != 9 {
|
||||||
|
t.Fatalf("reward config = %d/%d/%d (err %v), want 7/40/9", payout, daily, hourly, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The catalog page renders the form pre-filled with the current payout.
|
||||||
|
if _, body := consoleDo(h, http.MethodGet, "http://admin.test/_gm/catalog", "", ""); !strings.Contains(body, "reward_payout") || !strings.Contains(body, `value="7"`) {
|
||||||
|
t.Error("catalog page did not render the reward form with the current payout")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A negative value is refused (the service validates non-negativity).
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, reward, "reward_payout=-1", origin); code != http.StatusOK || !strings.Contains(body, "non-negative") {
|
||||||
|
t.Errorf("negative payout = %d, has 'non-negative' = %v", code, strings.Contains(body, "non-negative"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ const atomChips = "chips"
|
|||||||
// method are omitted (misconfigured or unavailable there). An untrusted context (empty Kind) has
|
// method are omitted (misconfigured or unavailable there). An untrusted context (empty Kind) has
|
||||||
// no method, so only values show; buying is gated server-side regardless.
|
// no method, so only values show; buying is gated server-side regardless.
|
||||||
func projectCatalog(entries []catalogEntry, cxt Context) CatalogView {
|
func projectCatalog(entries []catalogEntry, cxt Context) CatalogView {
|
||||||
|
// Present products in the canonical listing order shared with the offer and the admin console
|
||||||
|
// (packs first by rouble price, then values grouped hints → no-ads → combo and by chip price); the
|
||||||
|
// client renders them as received. Ordered in place — the caller passes a fresh loadCatalog result
|
||||||
|
// it does not reuse.
|
||||||
|
sortCatalogEntries(entries)
|
||||||
var out CatalogView
|
var out CatalogView
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
isPack := false
|
isPack := false
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package payments
|
package payments
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -144,10 +143,12 @@ func validateProduct(in ProductInput, sellable bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminCatalog lists every product (active and archived) with its composition, prices and whether it
|
// AdminCatalog lists products with their composition, prices and whether each has been transacted,
|
||||||
// has been transacted, for the admin editor. Read uncached, straight from the catalog tables.
|
// for the admin editor. includeInactive adds the archived products to the active ones (the console's
|
||||||
func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
// active/all toggle); with it false only active products are returned. Read uncached, straight from
|
||||||
return s.store.adminCatalog(ctx)
|
// 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
|
// SortAdminCatalog orders an admin catalog list in place the same way the public offer lists products
|
||||||
@@ -157,18 +158,7 @@ func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
|||||||
// products the same as active ones (the admin list shows both).
|
// products the same as active ones (the admin list shows both).
|
||||||
func SortAdminCatalog(products []AdminProduct) {
|
func SortAdminCatalog(products []AdminProduct) {
|
||||||
slices.SortStableFunc(products, func(a, b AdminProduct) int {
|
slices.SortStableFunc(products, func(a, b AdminProduct) int {
|
||||||
if pa, pb := adminIsPack(a), adminIsPack(b); pa != pb {
|
return compareCatalogRank(adminRank(a), adminRank(b))
|
||||||
if pa {
|
|
||||||
return -1 // packs (sales) before values (chip exchange)
|
|
||||||
}
|
|
||||||
return 1
|
|
||||||
} else if pa {
|
|
||||||
return cmp.Compare(adminPriceAmount(a, string(SourceDirect), CurrencyRUB), adminPriceAmount(b, string(SourceDirect), CurrencyRUB))
|
|
||||||
}
|
|
||||||
if d := cmp.Compare(adminValueGroup(a), adminValueGroup(b)); d != 0 {
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
return cmp.Compare(adminPriceAmount(a, "", CurrencyChip), adminPriceAmount(b, "", CurrencyChip))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
// catalogRank is the canonical listing key for a catalog product: whether it is a chip pack, its
|
||||||
|
// value subgroup (see [valueGroup]; unused for a pack) and the minor-unit amount its section sorts by
|
||||||
|
// (a pack's direct rouble price, a value's chip price; math.MaxInt64 when absent, so a misconfigured
|
||||||
|
// row sorts last rather than leading).
|
||||||
|
type catalogRank struct {
|
||||||
|
pack bool
|
||||||
|
group int
|
||||||
|
amount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// compareCatalogRank is the single canonical product order, shared by the storefront ([projectCatalog]),
|
||||||
|
// the public offer ([projectOfferPricing]) and the admin catalog ([SortAdminCatalog]): chip packs
|
||||||
|
// first, ascending by rouble price; then chip-priced values grouped hints → no-ads → combo →
|
||||||
|
// tournament and, within a group, ascending by chip price. A pack's group is unused (all packs share
|
||||||
|
// one section). Callers use it through [entryRank] / [adminRank], which build the key from each
|
||||||
|
// product shape, so the subgroup and ordering rules live in exactly one place.
|
||||||
|
func compareCatalogRank(a, b catalogRank) int {
|
||||||
|
if a.pack != b.pack {
|
||||||
|
if a.pack {
|
||||||
|
return -1 // packs (sales) before values (chip exchange)
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if !a.pack {
|
||||||
|
if d := cmp.Compare(a.group, b.group); d != 0 {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cmp.Compare(a.amount, b.amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// entryRank builds the canonical rank of a storefront / offer catalog entry.
|
||||||
|
func entryRank(e catalogEntry) catalogRank {
|
||||||
|
if isPackEntry(e) {
|
||||||
|
return catalogRank{pack: true, amount: offerSortAmount(e, string(SourceDirect), CurrencyRUB)}
|
||||||
|
}
|
||||||
|
return catalogRank{group: offerValueGroup(e), amount: offerSortAmount(e, "", CurrencyChip)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminRank builds the canonical rank of an admin catalog product.
|
||||||
|
func adminRank(p AdminProduct) catalogRank {
|
||||||
|
if adminIsPack(p) {
|
||||||
|
return catalogRank{pack: true, amount: adminPriceAmount(p, string(SourceDirect), CurrencyRUB)}
|
||||||
|
}
|
||||||
|
return catalogRank{group: adminValueGroup(p), amount: adminPriceAmount(p, "", CurrencyChip)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortCatalogEntries orders storefront / offer entries in place into the canonical listing order
|
||||||
|
// ([compareCatalogRank]). Stable, so equal-keyed products keep their incoming (creation) order.
|
||||||
|
func sortCatalogEntries(entries []catalogEntry) {
|
||||||
|
slices.SortStableFunc(entries, func(a, b catalogEntry) int {
|
||||||
|
return compareCatalogRank(entryRank(a), entryRank(b))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -135,6 +135,56 @@ func TestProjectCatalog_Empty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProjectCatalog_CanonicalOrder checks the storefront lists products in the shared canonical order
|
||||||
|
// (compareCatalogRank): chip packs first ascending by rouble price, then chip-priced values grouped
|
||||||
|
// hints → no-ads → combo and ascending by chip price — the same order as the offer and the admin
|
||||||
|
// console, regardless of catalog creation order.
|
||||||
|
func TestProjectCatalog_CanonicalOrder(t *testing.T) {
|
||||||
|
pack := func(title string, rub int64) catalogEntry {
|
||||||
|
return catalogEntry{
|
||||||
|
id: uuid.New(),
|
||||||
|
title: title,
|
||||||
|
atoms: []atomQty{{atomType: atomChips, quantity: 1}},
|
||||||
|
prices: []priceRow{{method: "direct", currency: CurrencyRUB, amount: rub}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value := func(title string, chips int64, atoms ...string) catalogEntry {
|
||||||
|
e := catalogEntry{id: uuid.New(), title: title, prices: []priceRow{{method: "", currency: CurrencyChip, amount: chips}}}
|
||||||
|
for _, a := range atoms {
|
||||||
|
e.atoms = append(e.atoms, atomQty{atomType: a, quantity: 1})
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
// Deliberately shuffled on input; the projection must impose the canonical order.
|
||||||
|
entries := []catalogEntry{
|
||||||
|
pack("packDear", 30000),
|
||||||
|
value("bundle", 500, "hints", "noads_days"),
|
||||||
|
pack("packCheap", 10000),
|
||||||
|
value("adsOnly", 150, "noads_days"),
|
||||||
|
value("hintsBig", 200, "hints"),
|
||||||
|
value("hintsSmall", 50, "hints"),
|
||||||
|
}
|
||||||
|
got := projectCatalog(entries, Context{Kind: SourceDirect})
|
||||||
|
want := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
|
||||||
|
if len(got.Products) != len(want) {
|
||||||
|
t.Fatalf("projected %d products, want %d", len(got.Products), len(want))
|
||||||
|
}
|
||||||
|
for i, p := range got.Products {
|
||||||
|
if p.Title != want[i] {
|
||||||
|
t.Fatalf("order[%d] = %q, want %q (full: %v)", i, p.Title, want[i], productTitles(got.Products))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// productTitles extracts the projected product titles for a failure message.
|
||||||
|
func productTitles(products []CatalogProduct) []string {
|
||||||
|
out := make([]string, len(products))
|
||||||
|
for i, p := range products {
|
||||||
|
out[i] = p.Title
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// TestSortAdminCatalog checks the admin list is ordered like the public offer: chip packs first,
|
// TestSortAdminCatalog checks the admin list is ordered like the public offer: chip packs first,
|
||||||
// ascending by rouble price; then values, grouped (hints only -> no-ads only -> no-ads + hints) and
|
// ascending by rouble price; then values, grouped (hints only -> no-ads only -> no-ads + hints) and
|
||||||
// ascending by chip price within a group. Stable and applied to active + archived alike.
|
// ascending by chip price within a group. Stable and applied to active + archived alike.
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package payments
|
package payments
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
@@ -60,8 +59,13 @@ func (s *Service) buildOfferPricing(ctx context.Context) (string, error) {
|
|||||||
// omitted. Amounts are rendered through [Money] so no floating point ever reaches the page (roubles
|
// omitted. Amounts are rendered through [Money] so no floating point ever reaches the page (roubles
|
||||||
// show kopecks as "200.00", whole-unit rails as integers); a missing rail price shows an em dash.
|
// show kopecks as "200.00", whole-unit rails as integers); a missing rail price shows an em dash.
|
||||||
func projectOfferPricing(entries []catalogEntry) string {
|
func projectOfferPricing(entries []catalogEntry) string {
|
||||||
|
// Order the whole catalog once by the shared canonical rank, then split it into the two offer
|
||||||
|
// tables (packs first, then the chip-exchange values); each keeps that order. Sort a copy so the
|
||||||
|
// caller's slice is left untouched.
|
||||||
|
sorted := slices.Clone(entries)
|
||||||
|
sortCatalogEntries(sorted)
|
||||||
var packs, values []catalogEntry
|
var packs, values []catalogEntry
|
||||||
for _, e := range entries {
|
for _, e := range sorted {
|
||||||
if isPackEntry(e) {
|
if isPackEntry(e) {
|
||||||
packs = append(packs, e)
|
packs = append(packs, e)
|
||||||
} else {
|
} else {
|
||||||
@@ -69,18 +73,6 @@ func projectOfferPricing(entries []catalogEntry) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Packs: ascending by the rouble price (the offer's base currency); a pack with no rouble price
|
|
||||||
// sorts last. Values: by group, then ascending chip price. Stable, so the catalog order breaks ties.
|
|
||||||
slices.SortStableFunc(packs, func(a, b catalogEntry) int {
|
|
||||||
return cmp.Compare(offerSortAmount(a, string(SourceDirect), CurrencyRUB), offerSortAmount(b, string(SourceDirect), CurrencyRUB))
|
|
||||||
})
|
|
||||||
slices.SortStableFunc(values, func(a, b catalogEntry) int {
|
|
||||||
if d := cmp.Compare(offerValueGroup(a), offerValueGroup(b)); d != 0 {
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
return cmp.Compare(offerSortAmount(a, "", CurrencyChip), offerSortAmount(b, "", CurrencyChip))
|
|
||||||
})
|
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
if len(packs) > 0 {
|
if len(packs) > 0 {
|
||||||
b.WriteString("Приобретение внутриигровой валюты «Фишка»:\n\n")
|
b.WriteString("Приобретение внутриигровой валюты «Фишка»:\n\n")
|
||||||
|
|||||||
@@ -157,6 +157,22 @@ func (s *Service) RewardPayout(ctx context.Context, cxt Context, present []Sourc
|
|||||||
return payout, err
|
return payout, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RewardConfig reads the rewarded-video config for the admin editor: the payout (chips per view) and
|
||||||
|
// the per-day and per-hour caps.
|
||||||
|
func (s *Service) RewardConfig(ctx context.Context) (payout, dailyCap, hourlyCap int, err error) {
|
||||||
|
return s.store.rewardConfig(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRewardConfig updates the rewarded-video config (the payout and the two caps). All three must be
|
||||||
|
// non-negative; a 0 payout leaves rewarded inert. It is not a catalog product, so it does not mark the
|
||||||
|
// offer stale.
|
||||||
|
func (s *Service) SetRewardConfig(ctx context.Context, payout, dailyCap, hourlyCap int) error {
|
||||||
|
if payout < 0 || dailyCap < 0 || hourlyCap < 0 {
|
||||||
|
return errors.New("payments: reward payout and caps must be non-negative")
|
||||||
|
}
|
||||||
|
return s.store.setRewardConfig(ctx, payout, dailyCap, hourlyCap)
|
||||||
|
}
|
||||||
|
|
||||||
// CreditReward credits a rewarded-video view's chips to the VK segment, client-attested (VK Mini App
|
// CreditReward credits a rewarded-video view's chips to the VK segment, client-attested (VK Mini App
|
||||||
// ads expose no server verify — the client's watch result is trusted for an honest user; a forger who
|
// ads expose no server verify — the client's watch result is trusted for an honest user; a forger who
|
||||||
// skips the ad and calls the endpoint is bounded by the config daily cap). It is idempotent on the
|
// skips the ad and calls the endpoint is bounded by the config daily cap). It is idempotent on the
|
||||||
|
|||||||
@@ -19,11 +19,17 @@ import (
|
|||||||
// row references — it must be archived instead, to keep the append-only ledger resolvable.
|
// row references — it must be archived instead, to keep the append-only ledger resolvable.
|
||||||
var ErrProductTransacted = errors.New("payments: product has transactions; archive instead of delete")
|
var ErrProductTransacted = errors.New("payments: product has transactions; archive instead of delete")
|
||||||
|
|
||||||
// adminCatalog lists every product (active and archived) with its atoms, prices and transacted flag.
|
// adminCatalog lists products with their atoms, prices and transacted flag. includeInactive adds the
|
||||||
func (s *Store) adminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
// archived products to the active ones; with it false only active products are returned.
|
||||||
|
func (s *Store) adminCatalog(ctx context.Context, includeInactive bool) ([]AdminProduct, error) {
|
||||||
|
where := table.Product.Active.IS_TRUE()
|
||||||
|
if includeInactive {
|
||||||
|
where = postgres.Bool(true)
|
||||||
|
}
|
||||||
var prods []model.Product
|
var prods []model.Product
|
||||||
if err := postgres.SELECT(table.Product.AllColumns).
|
if err := postgres.SELECT(table.Product.AllColumns).
|
||||||
FROM(table.Product).
|
FROM(table.Product).
|
||||||
|
WHERE(where).
|
||||||
ORDER_BY(table.Product.CreatedAt.ASC()).
|
ORDER_BY(table.Product.CreatedAt.ASC()).
|
||||||
QueryContext(ctx, s.db, &prods); err != nil {
|
QueryContext(ctx, s.db, &prods); err != nil {
|
||||||
return nil, fmt.Errorf("payments: admin list products: %w", err)
|
return nil, fmt.Errorf("payments: admin list products: %w", err)
|
||||||
|
|||||||
@@ -413,6 +413,18 @@ func (s *Store) rewardConfig(ctx context.Context) (payout, dailyCap, hourlyCap i
|
|||||||
return int(cfg.RewardedPayoutChips), int(cfg.RewardDailyCap), int(cfg.RewardHourlyCap), nil
|
return int(cfg.RewardedPayoutChips), int(cfg.RewardDailyCap), int(cfg.RewardHourlyCap), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setRewardConfig updates the rewarded payout and the per-day and per-hour caps on the singleton
|
||||||
|
// config row (no WHERE — the table holds exactly one row). Non-negativity is validated by the caller
|
||||||
|
// and also enforced by the config CHECK constraints.
|
||||||
|
func (s *Store) setRewardConfig(ctx context.Context, payout, dailyCap, hourlyCap int) error {
|
||||||
|
if _, err := s.db.ExecContext(ctx,
|
||||||
|
`UPDATE payments.config SET rewarded_payout_chips=$1, reward_daily_cap=$2, reward_hourly_cap=$3`,
|
||||||
|
payout, dailyCap, hourlyCap); err != nil {
|
||||||
|
return fmt.Errorf("payments: set reward config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// creditReward credits a rewarded-video view's chips to the funded segment, client-attested (VK Mini
|
// creditReward credits a rewarded-video view's chips to the funded segment, client-attested (VK Mini
|
||||||
// App ads expose no server verify). It reads the payout and daily cap from config: a 0 payout
|
// App ads expose no server verify). It reads the payout and daily cap from config: a 0 payout
|
||||||
// (unconfigured) or a reached cap credits nothing. It is idempotent on the client nonce (dedup on the
|
// (unconfigured) or a reached cap credits nothing. It is idempotent on the client nonce (dedup on the
|
||||||
|
|||||||
@@ -33,10 +33,12 @@ var priceFields = []struct {
|
|||||||
{"price_chip", "", payments.CurrencyChip},
|
{"price_chip", "", payments.CurrencyChip},
|
||||||
}
|
}
|
||||||
|
|
||||||
// consoleCatalog lists every product (active and archived) with its composition, prices, transacted
|
// consoleCatalog lists the catalog products with their composition, prices, transacted flag, and the
|
||||||
// flag, and the inline create form.
|
// inline create form. It shows active products by default; ?all=1 also lists the archived ones (the
|
||||||
|
// active/all toggle).
|
||||||
func (s *Server) consoleCatalog(c *gin.Context) {
|
func (s *Server) consoleCatalog(c *gin.Context) {
|
||||||
products, err := s.payments.AdminCatalog(c.Request.Context())
|
showAll := c.Query("all") == "1"
|
||||||
|
products, err := s.payments.AdminCatalog(c.Request.Context(), showAll)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.consoleError(c, err)
|
s.consoleError(c, err)
|
||||||
return
|
return
|
||||||
@@ -44,13 +46,32 @@ func (s *Server) consoleCatalog(c *gin.Context) {
|
|||||||
// Order the list like the public offer: sales (chip packs) first, then the chip-exchange values,
|
// Order the list like the public offer: sales (chip packs) first, then the chip-exchange values,
|
||||||
// grouped and price-sorted the same way, so the console mirrors what a buyer sees.
|
// grouped and price-sorted the same way, so the console mirrors what a buyer sees.
|
||||||
payments.SortAdminCatalog(products)
|
payments.SortAdminCatalog(products)
|
||||||
var view adminconsole.CatalogView
|
payout, daily, hourly, err := s.payments.RewardConfig(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.consoleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view := adminconsole.CatalogView{ShowAll: showAll, RewardPayout: payout, RewardDailyCap: daily, RewardHourlyCap: hourly}
|
||||||
for _, p := range products {
|
for _, p := range products {
|
||||||
view.Products = append(view.Products, catalogRow(p))
|
view.Products = append(view.Products, catalogRow(p))
|
||||||
}
|
}
|
||||||
s.renderConsole(c, "catalog", "catalog", "Catalog", view)
|
s.renderConsole(c, "catalog", "catalog", "Catalog", view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// consoleSetReward updates the rewarded-video config (chips per view plus the per-day and per-hour
|
||||||
|
// caps) from the catalog page's rewarded-ads form. A blank field reads as 0; a negative value is
|
||||||
|
// refused by the service.
|
||||||
|
func (s *Server) consoleSetReward(c *gin.Context) {
|
||||||
|
payout, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("reward_payout")))
|
||||||
|
daily, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("reward_daily")))
|
||||||
|
hourly, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("reward_hourly")))
|
||||||
|
if err := s.payments.SetRewardConfig(c.Request.Context(), payout, daily, hourly); err != nil {
|
||||||
|
s.renderConsoleMessage(c, "Update failed", err.Error(), catalogBack)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderConsoleMessage(c, "Saved", "the rewarded-ads config was updated", catalogBack)
|
||||||
|
}
|
||||||
|
|
||||||
// catalogRow projects a product into its list row.
|
// catalogRow projects a product into its list row.
|
||||||
func catalogRow(p payments.AdminProduct) adminconsole.ProductRow {
|
func catalogRow(p payments.AdminProduct) adminconsole.ProductRow {
|
||||||
row := adminconsole.ProductRow{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted}
|
row := adminconsole.ProductRow{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted}
|
||||||
@@ -69,7 +90,7 @@ func (s *Server) consoleCatalogDetail(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
products, err := s.payments.AdminCatalog(c.Request.Context())
|
products, err := s.payments.AdminCatalog(c.Request.Context(), true) // include archived: the detail form edits any product
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.consoleError(c, err)
|
s.consoleError(c, err)
|
||||||
return
|
return
|
||||||
@@ -242,7 +263,7 @@ func (s *Server) consoleGrantProduct(c *gin.Context) {
|
|||||||
// bundles, including archived ones — chips and tournament products are excluded).
|
// bundles, including archived ones — chips and tournament products are excluded).
|
||||||
func (s *Server) grantForm(ctx context.Context) adminconsole.GrantFormView {
|
func (s *Server) grantForm(ctx context.Context) adminconsole.GrantFormView {
|
||||||
fv := adminconsole.GrantFormView{Present: true, Origins: []string{"direct", "vk", "telegram"}}
|
fv := adminconsole.GrantFormView{Present: true, Origins: []string{"direct", "vk", "telegram"}}
|
||||||
products, err := s.payments.AdminCatalog(ctx)
|
products, err := s.payments.AdminCatalog(ctx, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fv
|
return fv
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
if s.payments != nil {
|
if s.payments != nil {
|
||||||
gm.GET("/catalog", s.consoleCatalog)
|
gm.GET("/catalog", s.consoleCatalog)
|
||||||
gm.POST("/catalog", s.consoleCreateProduct)
|
gm.POST("/catalog", s.consoleCreateProduct)
|
||||||
|
gm.POST("/catalog/reward", s.consoleSetReward)
|
||||||
gm.GET("/catalog/:id", s.consoleCatalogDetail)
|
gm.GET("/catalog/:id", s.consoleCatalogDetail)
|
||||||
gm.POST("/catalog/:id", s.consoleUpdateProduct)
|
gm.POST("/catalog/:id", s.consoleUpdateProduct)
|
||||||
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
|
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ func Middleware(logger *zap.Logger) gin.HandlerFunc {
|
|||||||
zap.String("path", route),
|
zap.String("path", route),
|
||||||
zap.Int("status", status),
|
zap.Int("status", status),
|
||||||
zap.Duration("latency", elapsed),
|
zap.Duration("latency", elapsed),
|
||||||
|
// The gateway forwards the real caller as X-Forwarded-For (and Caddy does for /_gm), which
|
||||||
|
// gin resolves here — so the access log carries the client IP, not the gateway's connection.
|
||||||
|
zap.String("client_ip", c.ClientIP()),
|
||||||
}
|
}
|
||||||
fields = append(fields, TraceFieldsFromContext(ctx)...)
|
fields = append(fields, TraceFieldsFromContext(ctx)...)
|
||||||
|
|
||||||
|
|||||||
@@ -150,6 +150,13 @@
|
|||||||
enabled: true
|
enabled: true
|
||||||
state: started
|
state: started
|
||||||
|
|
||||||
|
# Pin every host to UTC so host-level timestamps (journald, file mtimes, cron) line up
|
||||||
|
# across the fleet — some VPS images ship a local zone (the tg host came up on MSK). The
|
||||||
|
# services themselves run in UTC regardless; this is about host-side log correlation.
|
||||||
|
- name: Set the system timezone to UTC
|
||||||
|
community.general.timezone:
|
||||||
|
name: Etc/UTC
|
||||||
|
|
||||||
# --- Swap file (OOM cushion) ---------------------------------------------------
|
# --- Swap file (OOM cushion) ---------------------------------------------------
|
||||||
# The prod main host is tight (1.9 GiB) and the per-container memory caps
|
# The prod main host is tight (1.9 GiB) and the per-container memory caps
|
||||||
# (docker-compose.prod.yml) sum to more than RAM, so a simultaneous spike could hit
|
# (docker-compose.prod.yml) sum to more than RAM, so a simultaneous spike could hit
|
||||||
|
|||||||
@@ -151,7 +151,10 @@ dropped). Horizontal scaling is explicit future work.
|
|||||||
and GCG are unaffected** (they stay decoded concrete characters, §9.1).
|
and GCG are unaffected** (they stay decoded concrete characters, §9.1).
|
||||||
- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
|
- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
|
||||||
`X-User-ID` (and the session's trusted `X-Platform`, §3) for authenticated
|
`X-User-ID` (and the session's trusted `X-Platform`, §3) for authenticated
|
||||||
requests; `backend` never re-derives identity or platform from the body. Because every sync call targets the one backend host, the
|
requests, plus the caller's real IP as **`X-Forwarded-For`** on **every** call
|
||||||
|
(carried on the request context), so the backend records the real caller — the
|
||||||
|
account's last-login IP, chat/feedback moderation, the access log — rather than
|
||||||
|
the gateway's own connection address; `backend` never re-derives identity or platform from the body. Because every sync call targets the one backend host, the
|
||||||
gateway's REST client widens its keep-alive pool well past the stdlib default
|
gateway's REST client widens its keep-alive pool well past the stdlib default
|
||||||
of 2 idle connections per host; otherwise the per-request connection churn
|
of 2 idle connections per host; otherwise the per-request connection churn
|
||||||
exhausts ephemeral ports and burns gateway CPU under load (see
|
exhausts ephemeral ports and burns gateway CPU under load (see
|
||||||
@@ -1496,7 +1499,8 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
|
|||||||
forwards the domain to `scrabble:80`, so the in-compose caddy serves plain HTTP
|
forwards the domain to `scrabble:80`, so the in-compose caddy serves plain HTTP
|
||||||
(`CADDY_SITE_ADDRESS=:80`). The in-compose caddy **trusts X-Forwarded-For from
|
(`CADDY_SITE_ADDRESS=:80`). The in-compose caddy **trusts X-Forwarded-For from
|
||||||
private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP —
|
private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP —
|
||||||
used for chat-moderation logging and the gateway's per-IP rate limiting — survives the
|
used for the gateway's per-IP rate limiting and, forwarded on to the backend, the account's
|
||||||
|
last-login IP, chat/feedback moderation and the access log — survives the
|
||||||
host-caddy hop; in prod (no host caddy) public clients are untrusted and Caddy uses the
|
host-caddy hop; in prod (no host caddy) public clients are untrusted and Caddy uses the
|
||||||
real peer, so the single config is correct and spoof-safe in both contours. The
|
real peer, so the single config is correct and spoof-safe in both contours. The
|
||||||
**bot-link mTLS material** (a private CA + gateway/bot leaves, CN=`gateway`) is
|
**bot-link mTLS material** (a private CA + gateway/bot leaves, CN=`gateway`) is
|
||||||
|
|||||||
@@ -201,7 +201,9 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
|||||||
начисления): Фишки, подсказки, дни-без-рекламы, участие-в-турнире. **Продукт = набор
|
начисления): Фишки, подсказки, дни-без-рекламы, участие-в-турнире. **Продукт = набор
|
||||||
атомов + цена** (по одной ценности или комбо). «Пакет Фишек» — цена **per-метод**
|
атомов + цена** (по одной ценности или комбо). «Пакет Фишек» — цена **per-метод**
|
||||||
(мультивалютная: Голоса/Stars/руб — один продукт, D2). «Ценность за Фишки» — цена в
|
(мультивалютная: Голоса/Stars/руб — один продукт, D2). «Ценность за Фишки» — цена в
|
||||||
Фишках (единая). Курсы покупки Фишек и Фишки за rewarded — тоже в каталоге.
|
Фишках (единая). Курсы покупки Фишек и Фишки за rewarded — тоже в каталоге. (Ставка
|
||||||
|
rewarded + дневной/часовой потолки — строка общего конфига `payments.config`, правится в
|
||||||
|
секции «Rewarded ads» на странице каталога админки; это не продаваемый продукт-атом.)
|
||||||
- **D33. Стекинг «без рекламы»:** `paid_until[origin] += срок` от `max(now, текущий
|
- **D33. Стекинг «без рекламы»:** `paid_until[origin] += срок` от `max(now, текущий
|
||||||
конец)` (остаток не теряется, «плюсуются» как в документе). «Навсегда» — отдельный
|
конец)` (остаток не теряется, «плюсуются» как в документе). «Навсегда» — отдельный
|
||||||
вечный флаг (перекрывает сроки). Бонусы «(+50)» — маркетинговая пометка владельца;
|
вечный флаг (перекрывает сроки). Бонусы «(+50)» — маркетинговая пометка владельца;
|
||||||
|
|||||||
@@ -139,6 +139,26 @@ func platformFromContext(ctx context.Context) string {
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clientIPCtxKey types the request-context slot the originating client IP rides in.
|
||||||
|
type clientIPCtxKey struct{}
|
||||||
|
|
||||||
|
// WithClientIP returns a copy of ctx carrying the originating client IP that do injects as
|
||||||
|
// X-Forwarded-For on every downstream backend request — so the backend records the real caller
|
||||||
|
// (the account's last-login IP, chat/feedback moderation, the access log) rather than the gateway's
|
||||||
|
// own connection. An empty IP leaves ctx unchanged, so no header is sent.
|
||||||
|
func WithClientIP(ctx context.Context, ip string) context.Context {
|
||||||
|
if ip == "" {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, clientIPCtxKey{}, ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientIPFromContext returns the client IP stored by WithClientIP, or an empty string when none.
|
||||||
|
func clientIPFromContext(ctx context.Context) string {
|
||||||
|
ip, _ := ctx.Value(clientIPCtxKey{}).(string)
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
|
||||||
// do performs one REST call. userID, when non-empty, is forwarded as X-User-ID;
|
// do performs one REST call. userID, when non-empty, is forwarded as X-User-ID;
|
||||||
// clientIP, when non-empty, as X-Forwarded-For (for chat moderation); the trusted
|
// clientIP, when non-empty, as X-Forwarded-For (for chat moderation); the trusted
|
||||||
// platform carried on ctx (see WithPlatform), when present, as X-Platform. A non-2xx
|
// platform carried on ctx (see WithPlatform), when present, as X-Platform. A non-2xx
|
||||||
@@ -160,6 +180,13 @@ func (c *Client) do(ctx context.Context, method, path, userID, clientIP string,
|
|||||||
if userID != "" {
|
if userID != "" {
|
||||||
req.Header.Set("X-User-ID", userID)
|
req.Header.Set("X-User-ID", userID)
|
||||||
}
|
}
|
||||||
|
// The client IP rides X-Forwarded-For so the backend records the real caller. It comes from the
|
||||||
|
// explicit param (chat/feedback) or, for every other call, the request context (WithClientIP, set
|
||||||
|
// once per request in the Connect edge) — so the backend never falls back to the gateway's own
|
||||||
|
// connection address.
|
||||||
|
if clientIP == "" {
|
||||||
|
clientIP = clientIPFromContext(ctx)
|
||||||
|
}
|
||||||
if clientIP != "" {
|
if clientIP != "" {
|
||||||
req.Header.Set("X-Forwarded-For", clientIP)
|
req.Header.Set("X-Forwarded-For", clientIP)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,3 +122,44 @@ func TestXPlatformInjection(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestXForwardedForInjection verifies the client IP carried on the context (WithClientIP) rides every
|
||||||
|
// backend call as X-Forwarded-For — not just chat/feedback, which pass it explicitly — so the backend
|
||||||
|
// records the real caller (e.g. the account's last-login IP on the profile fetch) rather than the
|
||||||
|
// gateway's own connection. Absent when no client IP is set.
|
||||||
|
func TestXForwardedForInjection(t *testing.T) {
|
||||||
|
var gotXFF string
|
||||||
|
var hadHeader bool
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotXFF = r.Header.Get("X-Forwarded-For")
|
||||||
|
_, hadHeader = r.Header["X-Forwarded-For"]
|
||||||
|
_, _ = w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c, err := backendclient.New(srv.URL, "localhost:9090", 2*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("backendclient: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = c.Close() }()
|
||||||
|
|
||||||
|
t.Run("client IP on ctx rides a non-chat call", func(t *testing.T) {
|
||||||
|
ctx := backendclient.WithClientIP(context.Background(), "203.0.113.7")
|
||||||
|
if _, err := c.Profile(ctx, "user-1"); err != nil {
|
||||||
|
t.Fatalf("Profile: %v", err)
|
||||||
|
}
|
||||||
|
if gotXFF != "203.0.113.7" {
|
||||||
|
t.Fatalf("X-Forwarded-For = %q, want 203.0.113.7", gotXFF)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no client IP omits the header", func(t *testing.T) {
|
||||||
|
hadHeader = true
|
||||||
|
if _, err := c.Profile(context.Background(), "user-1"); err != nil {
|
||||||
|
t.Fatalf("Profile: %v", err)
|
||||||
|
}
|
||||||
|
if hadHeader {
|
||||||
|
t.Fatal("X-Forwarded-For must be absent when no client IP is set")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -418,6 +418,10 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
|
|||||||
return nil, connect.NewError(connect.CodeNotFound, errUnknownMessageType(msgType))
|
return nil, connect.NewError(connect.CodeNotFound, errUnknownMessageType(msgType))
|
||||||
}
|
}
|
||||||
clientIP := peerIP(req.Peer().Addr, req.Header())
|
clientIP := peerIP(req.Peer().Addr, req.Header())
|
||||||
|
// Carry the client IP on the context so the backend client injects it as X-Forwarded-For on every
|
||||||
|
// downstream REST call for this request — the account's last-login IP, chat/feedback moderation and
|
||||||
|
// the backend access log, not only the chat/feedback calls that pass it explicitly.
|
||||||
|
ctx = backendclient.WithClientIP(ctx, clientIP)
|
||||||
|
|
||||||
tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP}
|
tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP}
|
||||||
if op.Auth {
|
if op.Auth {
|
||||||
|
|||||||
Reference in New Issue
Block a user