Show T5 products in the client's shop; keep float IDs and unknown paths off IMVU
CI / build (push) Successful in 34s
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]>
This commit is contained in:
1 parent
6506c641f0
commit
2280ad6314
14 files changed
+785
-18
No files matched your search
@@ -0,0 +1,392 @@
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user