CI / build (push) Successful in 34s
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]>
393 lines
11 KiB
Go
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)
|
|
}
|