Files
t5de-proxy/internal/products/shop.go
T
dhkatzandClaude Opus 5.5 2280ad6314
CI / build (push) Successful in 34s
Show T5 products in the client's shop; keep float IDs and unknown paths off IMVU
The shop's T5 filter (maturity_rating "t5") is answered from the hub's products, fetched so they can be
shown and tried on; buying T5 products adds them to the hub library, and product.php details for
community products are answered here.

The client sends some product IDs as floats ("2000000008.0"): product.php passed the worn community
product to IMVU that way. Every ID is now read either way, and products.Guard refuses any request to
IMVU that still names a local ID given out. T5DE_PROXY_DUMP logs request and response bodies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:15:16 -07:00

393 lines
11 KiB
Go

package products
import (
"context"
"encoding/json"
"net/http"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
)
// The client's shop shows T5 products under its T5 filter (the t5de-shop patch), which searches with
// maturity_rating "t5": the proxy answers those searches from the hub instead of asking IMVU. Buying a T5 product
// adds it to the person's library on the hub; it's free, and IMVU never hears of it.
// ShopRating is the shop's maturity_rating for T5 products.
const ShopRating = "t5"
// shopKeys are search_st.php's columns, as IMVU sends them.
var shopKeys = []string{"id", "name", "price", "discount_price", "visible", "ap", "applus", "image", "creator_id",
"creator_name", "pro", "owns_product", "in_wishlist", "category", "category_name", "parent_categories",
"sub_pids", "index", "new", "sponsored", "purchasable"}
// shopFetchTime is how long a search waits for products to arrive from catalogs (the rest show on a later search).
var shopFetchTime = 15 * time.Second
func (p *Products) interceptShopSearch(x *proxy.Exchange) error {
var req struct {
Rating string `json:"maturity_rating"`
Search *string `json:"search"`
Category json.RawMessage `json:"category"`
Page json.Number `json:"page"`
Skip json.Number `json:"skip"`
PerPage json.Number `json:"results_per_page"`
Limit json.Number `json:"solr_limit"`
}
if json.Unmarshal(x.Body, &req) != nil || req.Rating != ShopRating {
return nil
}
if p.Shop == nil {
x.Response = localResponse(200, "application/json", shopAnswer(nil, 1, 0, 12, 24))
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), shopFetchTime+5*time.Second)
defer cancel()
query := ""
if req.Search != nil {
query = strings.TrimSpace(*req.Search)
}
list, err := p.Shop(ctx, query)
if err != nil {
p.logf("shop: searching the hub: %v", err)
x.Response = localResponse(502, "application/json", []byte(`{"error":"hub"}`))
return nil
}
p.mu.Lock()
if p.shopSeen == nil {
p.shopSeen = map[string]hub.Product{}
}
for _, hp := range list {
p.shopSeen[hp.ID] = hp
}
p.mu.Unlock()
list = inCategories(list, categoryIDs(req.Category))
p.fetchForShop(ctx, list)
var rows []shopRow
for _, hp := range list {
if s := p.byProductID(hp.ID); s != nil {
rows = append(rows, shopRow{Product: hp, local: s.local})
}
}
page, skip, perPage, limit := number(req.Page, 1), number(req.Skip, 0), number(req.PerPage, 12), number(req.Limit, 24)
if perPage < 1 {
perPage = 12
}
if page < 1 {
page = 1
}
if skip < 0 {
skip = (page - 1) * perPage
}
for i := range rows {
rows[i].owned = p.owns(rows[i].ID)
}
x.Response = localResponse(200, "application/json", shopAnswer(rows, page, skip, perPage, limit))
return nil
}
type shopRow struct {
hub.Product
local int64
owned bool
}
func (r shopRow) cells(index int) []any {
ap := 0
if r.Rating == "adult" {
ap = 1
}
category, categoryName := int64(0), ""
parents := []hub.Category{}
if n := len(r.Categories); n > 0 {
category, categoryName = r.Categories[n-1].ID, categoryText(r.Categories[n-1].Name)
for _, c := range r.Categories[:n-1] {
parents = append(parents, hub.Category{ID: c.ID, Name: categoryText(c.Name)})
}
}
return []any{strconv.FormatInt(r.local, 10), plain(r.Name), 0, 0, 1, ap, 0, r.Image, "0", plain(r.Creator), 0,
r.owned, 0, category, categoryName, parents, "", index, 0, 0, 1}
}
// categoryText is an IMVU category name (from the hub), kept to what plain allows plus "&" ("Coats & Jackets"):
// a lone "&" can't start markup.
func categoryText(s string) string {
parts := strings.Split(s, "&")
for i, part := range parts {
parts[i] = plain(part)
}
return strings.Join(parts, "&")
}
// owns reports whether the person has a product: local packs always, hub products in their library.
func (p *Products) owns(id string) bool {
if p.inHubDir(id) {
return p.Owned != nil && p.Owned(id)
}
return true
}
// object is the row as one of product.php's products (keys to values).
func (r shopRow) object() map[string]any {
cells := r.cells(0)
out := map[string]any{}
for i, k := range shopKeys {
out[k] = cells[i]
}
return out
}
// interceptProductInfo answers product.php's product details for community products, and asks IMVU only for its
// own.
func (p *Products) interceptProductInfo(x *proxy.Exchange) error {
var body map[string]json.RawMessage
if json.Unmarshal(x.Body, &body) != nil {
return nil
}
var pids []json.RawMessage
if json.Unmarshal(body["pids"], &pids) != nil {
return nil
}
var keep []json.RawMessage
var ours []any
community := 0
for _, raw := range pids {
id, ok := rawID(raw)
if !ok || !IsCommunity(id) {
keep = append(keep, raw)
continue
}
community++
if r, ok := p.shopRowFor(id); ok {
ours = append(ours, r.object())
}
}
if community == 0 {
return nil
}
if len(keep) == 0 {
b, _ := json.Marshal(map[string]any{"products": ours})
x.Response = localResponse(200, "application/json", b)
return nil
}
body["pids"], _ = json.Marshal(keep)
nb, _ := json.Marshal(body)
if !p.keys.resign(x.Request.Header, x.Body, x.URL.RawQuery, nb, x.URL.RawQuery) {
return errNoKey
}
x.Body = nb
x.ModifyResponse = func(resp *http.Response, b []byte) ([]byte, error) {
var r map[string]json.RawMessage
var list []any
if json.Unmarshal(b, &r) != nil || json.Unmarshal(r["products"], &list) != nil {
return b, nil
}
r["products"], _ = json.Marshal(append(list, ours...))
return json.Marshal(r)
}
return nil
}
// shopRowFor is a community product's shop entry: as the hub listed it, else from its pack.
func (p *Products) shopRowFor(local int64) (shopRow, bool) {
s := p.lookup(local)
if s == nil {
return shopRow{}, false
}
m := s.pack.Manifest
p.mu.Lock()
hp, ok := p.shopSeen[m.ID]
p.mu.Unlock()
if !ok {
hp = hub.Product{ID: m.ID, Name: m.Name, Creator: m.Creator, Rating: m.Rating, Latest: m.Revision}
}
return shopRow{Product: hp, local: s.local, owned: p.owns(m.ID)}, true
}
// rawID reads a product ID sent as a number or a string.
func rawID(raw json.RawMessage) (int64, bool) {
var s string
if json.Unmarshal(raw, &s) != nil {
var n json.Number
if json.Unmarshal(raw, &n) != nil {
return 0, false
}
s = n.String()
}
return parseID(s)
}
// shopAnswer is a search_st.php answer: the pages from page on, holding up to limit products from skip.
func shopAnswer(rows []shopRow, page, skip, perPage, limit int) []byte {
result := map[string][]any{}
end := min(skip+limit, len(rows))
for i := skip; i < end; i++ {
key := strconv.Itoa(page + (i-skip)/perPage)
result[key] = append(result[key], rows[i].cells(i))
}
b, _ := json.Marshal(map[string]any{
"keys": shopKeys,
"startIndex": skip,
"endIndex": end - 1,
"result": result,
"totalProductCount": len(rows),
"thisPageProductCount": max(end-skip, 0),
"pageCount": (len(rows) + perPage - 1) / perPage,
"thisPage": page,
})
return b
}
// categoryIDs reads the shop's category field: IDs separated by commas, as a string or number (empty: all).
func categoryIDs(raw json.RawMessage) map[int64]bool {
var s string
if json.Unmarshal(raw, &s) != nil {
var n json.Number
if json.Unmarshal(raw, &n) != nil {
return nil
}
s = n.String()
}
ids := map[int64]bool{}
for _, f := range strings.Split(s, ",") {
if id, ok := parseID(f); ok {
ids[id] = true
}
}
if len(ids) == 0 {
return nil
}
return ids
}
// inCategories keeps the products under any of the categories (all of them if there are none).
func inCategories(list []hub.Product, ids map[int64]bool) []hub.Product {
if ids == nil {
return list
}
var out []hub.Product
for _, hp := range list {
for _, c := range hp.Categories {
if ids[c.ID] {
out = append(out, hp)
break
}
}
}
return out
}
// fetchForShop gets the listed products that aren't here (or are an older revision), a few at a time, so the shop
// can show them and they can be tried on.
func (p *Products) fetchForShop(ctx context.Context, list []hub.Product) {
if p.Fetch == nil {
return
}
ctx, cancel := context.WithTimeout(ctx, shopFetchTime)
defer cancel()
var wg sync.WaitGroup
slots := make(chan struct{}, 6)
for _, hp := range list {
if s := p.byProductID(hp.ID); s != nil && s.pack.Manifest.Revision >= hp.Latest {
continue
}
wg.Add(1)
go func(id string) {
defer wg.Done()
select {
case slots <- struct{}{}:
case <-ctx.Done():
return
}
defer func() { <-slots }()
if err := p.Fetch(ctx, id); err != nil {
p.logf("shop: %s: %v", id, err)
}
}(hp.ID)
}
wg.Wait()
p.refresh(true)
}
func number(n json.Number, fallback int) int {
if v, err := n.Int64(); err == nil {
return int(v)
}
return fallback
}
// interceptPurchase buys T5 products by adding them to the person's library. Products from IMVU in the same
// purchase go on to IMVU without the T5 ones.
func (p *Products) interceptPurchase(x *proxy.Exchange) error {
var body map[string]json.RawMessage
if json.Unmarshal(x.Body, &body) != nil {
return nil
}
var pids []json.RawMessage
if json.Unmarshal(body["products"], &pids) != nil {
var one json.RawMessage
if json.Unmarshal(body["products"], &one) != nil {
return nil
}
pids = []json.RawMessage{one}
}
var keep []json.RawMessage
var t5 []string
community := 0
for _, raw := range pids {
id, ok := rawID(raw)
if !ok || !IsCommunity(id) {
keep = append(keep, raw)
continue
}
community++
if pid, ok := p.state.productID(id); ok && p.HubDir != "" && p.inHubDir(pid) {
t5 = append(t5, pid)
} // local packs are always in the inventory: nothing to buy
}
if community == 0 {
return nil
}
// IMVU's part first (re-signed), so nothing is added to the library for a purchase that can't go on.
if len(keep) > 0 {
body["products"], _ = json.Marshal(keep)
nb, _ := json.Marshal(body)
if !p.keys.resign(x.Request.Header, x.Body, x.URL.RawQuery, nb, x.URL.RawQuery) {
return errNoKey
}
x.Body = nb
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
for _, pid := range t5 {
if p.AddToLibrary == nil {
break
}
if err := p.AddToLibrary(ctx, pid); err != nil {
p.logf("shop: adding %s to the library: %v", pid, err)
x.Response = localResponse(200, "application/json", []byte(`{"status":3}`))
return nil
}
}
if len(t5) > 0 {
p.logf("shop: added %d T5 products to the library", len(t5))
}
if len(keep) == 0 {
// No credits change hands: the client's patch leaves the balance alone when the answer has none.
x.Response = localResponse(200, "application/json", []byte(`{"status":1}`))
}
return nil
}
// inHubDir reports whether a product is served from the hub's products (not a local pack).
func (p *Products) inHubDir(id string) bool {
s := p.byProductID(id)
return s != nil && filepath.Dir(s.dir) == filepath.Clean(p.HubDir)
}