package products import ( "context" "encoding/json" "net/http" "path/filepath" "sort" "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"` Sort string `json:"sort_field"` Dir string `json:"sort_direction"` 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)) sortShop(list, query, req.Sort, req.Dir) 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 } // sortShop orders products for the shop's sort menu. T5 products have no ratings or prices, so those sorts are by // newest; Best Match (conversion_score_b, score) with a search puts the closest names first (the same name, then // names starting with it, then words starting with it, then the rest), newest first among equals; Newest // (id) is newest first, or oldest first ascending. func sortShop(list []hub.Product, query, field, direction string) { q := strings.ToLower(strings.TrimSpace(query)) rank := func(hp hub.Product) int { if q == "" || (field != "conversion_score_b" && field != "score" && field != "") { return 0 } name := strings.ToLower(hp.Name) switch { case name == q: return 0 case strings.HasPrefix(name, q): return 1 case strings.Contains(" "+name, " "+q): return 2 case strings.Contains(name, q): return 3 } return 4 // matched by creator } oldest := field == "id" && direction == "asc" sort.SliceStable(list, func(i, j int) bool { if ri, rj := rank(list[i]), rank(list[j]); ri != rj { return ri < rj } if oldest { return list[i].CreatedAt.Before(list[j].CreatedAt) } return list[i].CreatedAt.After(list[j].CreatedAt) }) } // 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) }