Files
t5de-proxy/internal/products/intercept.go
T
dhkatzandClaude Opus 5.5 171e8173af
CI / build (push) Successful in 34s
Recently added, shop sorting, and outfit cards with T5 products
- The inventory's Recently Added puts T5 products added to the library in the last 30 days first.
- The shop's T5 listing follows its sort menu: Best Match ranks by name, Newest by upload date
  (oldest first ascending); ratings and prices (T5 products have neither) sort by newest.
- Outfit cards: outfit_products.php gets the outfit's community products added (not for sale),
  find_bundle.php is asked about IMVU's products only, and removing one in the card (remove_pids)
  keeps it from IMVU and forgets it in the outfit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:45:02 -07:00

738 lines
21 KiB
Go

package products
import (
"encoding/json"
"errors"
"net/http"
"net/url"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
"git.t5de.dev/t5de/t5de-proxy/internal/xmlrpc"
)
func localResponse(status int, contentType string, body []byte) *http.Response {
return proxy.LocalResponse(status, contentType, body)
}
// Intercept implements proxy.Interceptor.
func (p *Products) Intercept(x *proxy.Exchange) error {
host, path := strings.ToLower(x.URL.Hostname()), x.URL.Path
if strings.HasSuffix(host, ".imvu.com") && strings.HasPrefix(path, "/productdata/") {
if resp, ok := p.serveFile(path); ok {
x.Response = resp
}
return nil
}
if !strings.HasSuffix(host, ".imvu.com") {
return nil
}
if host == "api.imvu.com" && participantPath.MatchString(path) {
return p.interceptParticipant(x)
}
if host == "api.imvu.com" {
if m := productPath.FindStringSubmatch(path); m != nil {
if id, ok := parseID(m[1]); ok && IsCommunity(id) {
x.Response = productAPIAnswer(path, m[2])
return nil
}
}
}
switch path {
case "/api/xmlrpc/chat.php":
return p.interceptChat(x)
case "/api/xmlrpc/client.php":
return p.interceptXMLRPC(x)
case "/api/shop/product.php":
// Putting a product on asks the shop whether it's a bundle; community products aren't.
if id, ok := parseID(x.URL.Query().Get("pid")); ok && IsCommunity(id) {
x.Response = localResponse(200, "application/json", []byte(`{"products":[]}`))
} else if len(x.Body) > 0 {
return p.interceptProductInfo(x)
}
case "/api/outfit_products.php":
return p.interceptOutfitProducts(x)
case "/api/find_bundle.php":
return p.interceptFindBundle(x)
case "/api/recently_added_inventory.php":
return p.interceptRecent(x)
case "/api/shop/search_st.php":
return p.interceptShopSearch(x)
case "/api/service/shop/svc_catalog_purchase.php":
return p.interceptPurchase(x)
case "/api/base_look.php":
return p.interceptBaseLook(x)
case "/api/service/inventory/inventory_delete.php":
return p.interceptHide(x)
case "/api/outfits.php":
return p.interceptOutfits(x)
}
return nil
}
// productPath is IMVU's REST API for a product (api.imvu.com/product/product-<id>[/<relation>]).
var productPath = regexp.MustCompile(`^/product/product-([0-9.]+)(/[a-z_]+)?$`)
// productAPIAnswer answers the REST API about a community product: no related products (uml_products, which the
// shop's recommender asks for when a product is tried on), and nothing else.
func productAPIAnswer(path, relation string) *http.Response {
id := "https://api.imvu.com" + path
if relation == "/uml_products" {
b, _ := json.Marshal(map[string]any{"status": "success", "id": id, "denormalized": map[string]any{
id: map[string]any{"data": map[string]any{"total_count": 0, "items": []any{}}, "relations": map[string]any{}}}})
return localResponse(200, "application/json", b)
}
return localResponse(404, "application/json", []byte(`{"status":"failure","error":"NOT_FOUND"}`))
}
func (p *Products) interceptXMLRPC(x *proxy.Exchange) error {
call, err := xmlrpc.ParseCall(x.Body)
if err != nil {
return nil // not ours to judge; IMVU answers
}
switch call.Method {
case "test.avatarInfoForLogin2":
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
p.keys.learn(body)
if m := userIDPattern.FindSubmatch(body); m != nil {
p.mu.Lock()
p.user = string(m[1])
p.mu.Unlock()
}
if m := userIDPattern.FindSubmatch(body); m != nil && p.Observer != nil {
if id, err := strconv.ParseInt(string(m[1]), 10, 64); err == nil {
p.Observer.Session(id)
}
}
return p.addToLook(body)
}
case "test.inventoryProductBatch":
return p.inventoryProductBatch(x, call)
case "test.authorizeProducts2":
return p.authorizeProducts2(x, call)
case "test.showInventory":
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
return p.addToInventory(body)
}
}
return nil
}
// replaceBody sends call instead of the client's, re-signed.
func (p *Products) replaceBody(x *proxy.Exchange, call *xmlrpc.Call) error {
body := call.Marshal()
if !p.keys.resign(x.Request.Header, x.Body, x.URL.RawQuery, body, x.URL.RawQuery) {
return errNoKey
}
x.Body = body
return nil
}
// addToLook adds the user's community products to the outfit ("look") in a login response.
func (p *Products) addToLook(body []byte) ([]byte, error) {
m := userIDPattern.FindSubmatch(body)
if m == nil {
return body, nil
}
user := string(m[1])
r, err := xmlrpc.ParseResponse(body)
if err != nil || r.Fault != nil {
return body, err
}
changed := false
var walk func(v xmlrpc.Value) xmlrpc.Value
walk = func(v xmlrpc.Value) xmlrpc.Value {
switch v := v.(type) {
case xmlrpc.Struct:
for i, mem := range v {
if mem.Name == "look" {
if look, ok := productList(mem.Value); ok {
if worn := p.wornLocal(user, look); len(worn) > 0 {
p.reportOutfit(user, worn)
v[i].Value = appendIDs(mem.Value.([]xmlrpc.Value), worn)
changed = true
}
continue
}
}
v[i].Value = walk(mem.Value)
}
return v
case []xmlrpc.Value:
for i := range v {
v[i] = walk(v[i])
}
return v
}
return v
}
for i := range r.Params {
r.Params[i] = walk(r.Params[i])
}
if !changed {
return body, nil
}
return r.Marshal(), nil
}
func appendIDs(list []xmlrpc.Value, ids []int64) []xmlrpc.Value {
have := map[int64]bool{}
for _, v := range list {
if n, ok := v.(int64); ok {
have[n] = true
}
}
for _, id := range ids {
if !have[id] {
list = append(list, id)
}
}
return list
}
// productList reads a list of product IDs.
func productList(v xmlrpc.Value) ([]int64, bool) {
list, ok := v.([]xmlrpc.Value)
if !ok {
return nil, false
}
ids := make([]int64, 0, len(list))
for _, v := range list {
switch n := v.(type) {
case int64:
ids = append(ids, n)
case float64:
id, ok := parseID(strconv.FormatFloat(n, 'f', -1, 64))
if !ok {
return nil, false
}
ids = append(ids, id)
default:
return nil, false
}
}
return ids, true
}
// inventoryProductBatch answers product info for community products: the parent's info from IMVU with the
// product's own name, creator and ID, or (without a parent) info of its own.
func (p *Products) inventoryProductBatch(x *proxy.Exchange, call *xmlrpc.Call) error {
if len(call.Params) != 1 {
return nil
}
ids, ok := productList(call.Params[0])
if !ok {
return nil
}
var community []int64
var forward []xmlrpc.Value
asked := map[int64]bool{}
for _, id := range ids {
if !IsCommunity(id) {
if !asked[id] {
forward, asked[id] = append(forward, id), true
}
continue
}
community = append(community, id)
if s := p.lookup(id); s != nil && s.pack.Index.Parent > 0 && !asked[int64(s.pack.Index.Parent)] {
forward, asked[int64(s.pack.Index.Parent)] = append(forward, int64(s.pack.Index.Parent)), true
}
}
if len(community) == 0 {
return nil
}
// The answer, in the order asked, from IMVU's entries (by product ID) and our own.
answer := func(imvu map[int64]xmlrpc.Struct) []byte {
var out []xmlrpc.Value
for _, id := range ids {
if !IsCommunity(id) {
if e, ok := imvu[id]; ok {
out = append(out, e)
}
continue
}
if s := p.lookup(id); s != nil {
out = append(out, s.productInfo(imvu[int64(s.pack.Index.Parent)]))
}
}
return (&xmlrpc.Response{Params: []xmlrpc.Value{out}}).Marshal()
}
if len(forward) == 0 {
x.Response = localResponse(200, "text/xml", answer(nil))
return nil
}
if err := p.replaceBody(x, &xmlrpc.Call{Method: call.Method, Params: []xmlrpc.Value{forward}}); err != nil {
return err
}
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
r, err := xmlrpc.ParseResponse(body)
if err != nil || r.Fault != nil || len(r.Params) != 1 {
return body, err
}
list, _ := r.Params[0].([]xmlrpc.Value)
imvu := map[int64]xmlrpc.Struct{}
for _, v := range list {
if e, ok := v.(xmlrpc.Struct); ok {
if id, ok := e.Get("products_id"); ok {
if n, ok := id.(int64); ok {
imvu[n] = e
}
}
}
}
return answer(imvu), nil
}
return nil
}
// productInfo is a community product's entry for inventoryProductBatch, based on its parent's if there is one.
func (s *served) productInfo(parent xmlrpc.Struct) xmlrpc.Struct {
m := s.pack.Manifest
mature := "N"
if m.Rating == "adult" {
mature = "Y"
}
own := map[string]xmlrpc.Value{
"products_id": s.local,
"manufacturers_id": int64(0),
"manufacturers_name": plain(m.Creator),
"products_name": plain(m.Name),
"products_mature": mature,
"products_bundle": "",
"is_published": "1",
"is_purchasable": false,
}
var out xmlrpc.Struct
done := map[string]bool{}
for _, mem := range parent {
if v, ok := own[mem.Name]; ok {
out = append(out, xmlrpc.Member{Name: mem.Name, Value: v})
done[mem.Name] = true
} else {
out = append(out, mem) // cPath, products_image, ... from the parent
}
}
for _, name := range []string{"products_id", "manufacturers_id", "manufacturers_name", "products_name", "cPath",
"products_mature", "products_bundle", "products_image", "is_published", "is_purchasable"} {
if done[name] {
continue
}
v, ok := own[name]
if !ok {
v = "" // cPath and products_image, without a parent
}
if _, inParent := parent.Get(name); !inParent {
out = append(out, xmlrpc.Member{Name: name, Value: v})
}
}
return out
}
// authColumns are authorizeProducts2's answer columns, as IMVU sends them.
var authColumns = []string{"userId", "parentProductId", "productId", "cflChecksum", "cflUrl", "sub_pids",
"usableDirect", "autoAuth", "usableDerivation", "usableTrial", "trialTimeout", "usablePeerReview", "info"}
// authorizeProducts2 answers permission and file addresses for community products, and asks IMVU for their
// parents' (the client needs the whole derivation chain).
func (p *Products) authorizeProducts2(x *proxy.Exchange, call *xmlrpc.Call) error {
if len(call.Params) != 1 {
return nil
}
args, ok := call.Params[0].(xmlrpc.Struct)
if !ok {
return nil
}
authsValue, _ := args.Get("auths")
table, ok := authsValue.([]xmlrpc.Value)
if !ok || len(table) < 1 {
return nil
}
header, ok := table[0].([]xmlrpc.Value)
if !ok || len(header) != 2 || header[0] != "userId" || header[1] != "productId" {
return nil
}
type auth struct {
user, product int64
}
var community []auth
forward := []xmlrpc.Value{table[0]}
asked := map[auth]bool{}
for _, row := range table[1:] {
r, ok := row.([]xmlrpc.Value)
if !ok || len(r) != 2 {
return nil
}
user, ok1 := r[0].(int64)
product, ok2 := r[1].(int64)
if !ok1 || !ok2 {
return nil
}
if !IsCommunity(product) {
if !asked[auth{user, product}] {
forward, asked[auth{user, product}] = append(forward, row), true
}
continue
}
community = append(community, auth{user, product})
if s := p.lookup(product); s != nil && s.pack.Index.Parent > 0 {
a := auth{user, int64(s.pack.Index.Parent)}
if !asked[a] {
forward, asked[a] = append(forward, []xmlrpc.Value{user, a.product}), true
}
}
}
if len(community) == 0 {
return nil
}
ours := func(columns []xmlrpc.Value) []xmlrpc.Value {
var rows []xmlrpc.Value
for _, a := range community {
s := p.lookup(a.product)
if s == nil {
continue // unknown: no row, as IMVU does for products it can't authorize
}
values := map[string]xmlrpc.Value{
"userId": a.user, "parentProductId": int64(s.pack.Index.Parent), "productId": s.local,
"cflChecksum": s.checksum, "cflUrl": cflURL(s.local, s.pack.Manifest.Revision), "sub_pids": "",
"usableDirect": true, "autoAuth": true, "usableDerivation": int64(1), "usableTrial": true,
"trialTimeout": int64(40), "usablePeerReview": true, "info": "",
}
row := make([]xmlrpc.Value, len(columns))
for i, c := range columns {
name, _ := c.(string)
if v, ok := values[name]; ok {
row[i] = v
} else {
row[i] = ""
}
}
rows = append(rows, row)
}
return rows
}
if len(forward) == 1 { // only community products, none deriving from IMVU's
columns := make([]xmlrpc.Value, len(authColumns))
for i, c := range authColumns {
columns[i] = c
}
out := append([]xmlrpc.Value{columns}, ours(columns)...)
x.Response = localResponse(200, "text/xml", (&xmlrpc.Response{Params: []xmlrpc.Value{out}}).Marshal())
return nil
}
changed := make(xmlrpc.Struct, len(args))
copy(changed, args)
for i := range changed {
if changed[i].Name == "auths" {
changed[i].Value = forward
}
}
if err := p.replaceBody(x, &xmlrpc.Call{Method: call.Method, Params: []xmlrpc.Value{changed}}); err != nil {
return err
}
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
r, err := xmlrpc.ParseResponse(body)
if err != nil || r.Fault != nil || len(r.Params) != 1 {
return body, err
}
rows, ok := r.Params[0].([]xmlrpc.Value)
if !ok || len(rows) == 0 {
return body, errors.New("products: unexpected authorizeProducts2 answer")
}
columns, ok := rows[0].([]xmlrpc.Value)
if !ok {
return body, errors.New("products: unexpected authorizeProducts2 columns")
}
r.Params[0] = append(rows, ours(columns)...)
return r.Marshal(), nil
}
return nil
}
// interceptBaseLook keeps community products out of the outfit IMVU stores, and adds them back when the client
// loads it.
func (p *Products) interceptBaseLook(x *proxy.Exchange) error {
user := x.Request.Header.Get("X-imvu-userid")
if user == "" {
return nil
}
if len(x.Body) == 0 { // loading the outfit
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
var look map[string]json.RawMessage
if err := json.Unmarshal(body, &look); err != nil {
return body, err
}
var ids []int64
if err := json.Unmarshal(look["result"], &ids); err != nil {
return body, err
}
worn := p.wornLocal(user, ids)
p.reportOutfit(user, worn)
if len(worn) == 0 {
return body, nil
}
have := map[int64]bool{}
for _, id := range ids {
have[id] = true
}
for _, id := range worn {
if !have[id] {
ids = append(ids, id)
}
}
look["result"], _ = json.Marshal(ids)
return json.Marshal(look)
}
return nil
}
// Saving it: {"pids": [...]}.
var save map[string]json.RawMessage
if err := json.Unmarshal(x.Body, &save); err != nil {
return nil
}
var ids []int64
if err := json.Unmarshal(save["pids"], &ids); err != nil {
return nil
}
var kept, community []int64
for _, id := range ids {
if IsCommunity(id) {
community = append(community, id)
} else {
kept = append(kept, id)
}
}
p.recordWorn(user, kept, community)
if len(community) == 0 {
return nil
}
p.logf("products: kept %d community products out of the outfit saved to IMVU", len(community))
save["pids"], _ = json.Marshal(kept)
body, _ := json.Marshal(save)
if !p.keys.resign(x.Request.Header, x.Body, x.URL.RawQuery, body, x.URL.RawQuery) {
return errNoKey
}
x.Body = body
return nil
}
// Outgoing implements proxy.IMQFilter: community products are taken out of outfit chat commands (IMVU and other
// people see the defaults), and whole outfits are remembered.
func (p *Products) Outgoing(queue, mount string, message []byte) ([]byte, bool) {
if !strings.HasPrefix(queue, "/chat/") || mount != "messages" {
return message, false
}
m, ok := parseChatMessage(message)
if !ok {
return message, false
}
r := stripCommand(m.text, IsCommunity)
if r.outfit && m.userID != 0 {
p.recordWorn(strconv.FormatInt(m.userID, 10), r.kept, r.products)
}
if !r.changed {
return message, false
}
command, _, _ := strings.Cut(r.text, " ")
if r.empty {
p.logf("products: answered a %s of only community products without sending it", command)
return nil, true
}
p.logf("products: kept %d community products out of a %s sent to IMVU", len(r.products), command)
return m.withText(r.text), false
}
// plain keeps a name to characters the client's pages can show however they insert it: some insert creator and
// product names as HTML, in pages that can call into the client.
func plain(s string) string {
return strings.Map(func(r rune) rune {
switch r {
case '<', '>', '&', '"', '\'', '`':
return -1
}
return r
}, s)
}
// inventoryLocals is the community products in the inventory: local packs, and hub products in the library.
func (p *Products) inventoryLocals() []int64 {
p.refresh(false)
p.mu.Lock()
defer p.mu.Unlock()
var ids []int64
for id, s := range p.byID {
if p.HubDir != "" && filepath.Dir(s.dir) == filepath.Clean(p.HubDir) && (p.Owned == nil || !p.Owned(id)) {
continue
}
ids = append(ids, s.local)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}
// addToInventory adds the community products to test.showInventory's answer (a list of product IDs).
func (p *Products) addToInventory(body []byte) ([]byte, error) {
r, err := xmlrpc.ParseResponse(body)
if err != nil || r.Fault != nil || len(r.Params) != 1 {
return body, nil
}
list, ok := r.Params[0].([]xmlrpc.Value)
if !ok {
return body, nil
}
have := map[int64]bool{}
for _, v := range list {
if n, ok := v.(int64); ok {
have[n] = true
}
}
added := 0
for _, id := range p.inventoryLocals() {
if !have[id] {
list = append(list, id)
added++
}
}
if added == 0 {
return body, nil
}
r.Params[0] = list
p.logf("products: %d community products in the inventory", added)
return r.Marshal(), nil
}
// interceptHide answers hiding a community product in the inventory itself (IMVU doesn't know it).
func (p *Products) interceptHide(x *proxy.Exchange) error {
form, err := url.ParseQuery(string(x.Body))
if err != nil {
return nil
}
pid, ok := parseID(form.Get("pid"))
if !ok || !IsCommunity(pid) {
return nil
}
if id, ok := p.state.productID(pid); ok && p.OnHide != nil {
p.OnHide(id)
}
x.Response = localResponse(200, "application/json", []byte(`{"success": true}`))
return nil
}
// interceptOutfits keeps community products out of saved outfits IMVU stores (POST /api/outfits.php, JSON with
// "products": "1,2,3"), remembering them under the outfit's ID, and adds them back when the client loads saved
// outfits (GET /api/outfits.php?batch=... or ?outfit_id=...: {"result": [{"user_outfits_id", "pids", ...}]}).
func (p *Products) interceptOutfits(x *proxy.Exchange) error {
if len(x.Body) > 0 {
var save map[string]json.RawMessage
if json.Unmarshal(x.Body, &save) != nil {
return nil
}
removed := p.outfitRemovals(save) // the outfit card's remove_pids
resave := func() error {
body, _ := json.Marshal(save)
if !p.keys.resign(x.Request.Header, x.Body, x.URL.RawQuery, body, x.URL.RawQuery) {
return errNoKey
}
x.Body = body
return nil
}
var products string
if json.Unmarshal(save["products"], &products) != nil {
if removed {
return resave()
}
return nil // not a save (e.g. set_default_outfit_id)
}
var kept []string
var community []string
stripped := 0
for _, f := range strings.Split(products, ",") {
id, ok := parseID(f)
if ok && IsCommunity(id) {
stripped++ // always kept from IMVU, even if its product isn't known anymore
if pid, ok := p.state.productID(id); ok {
community = append(community, pid)
}
continue
}
kept = append(kept, f)
}
if stripped == 0 {
if removed {
return resave()
}
return nil
}
save["products"], _ = json.Marshal(strings.Join(kept, ","))
body, _ := json.Marshal(save)
if !p.keys.resign(x.Request.Header, x.Body, x.URL.RawQuery, body, x.URL.RawQuery) {
return errNoKey
}
x.Body = body
p.logf("products: kept %d community products out of an outfit saved to IMVU", stripped)
x.ModifyResponse = func(resp *http.Response, b []byte) ([]byte, error) {
var r struct {
Result struct {
OutfitID json.Number `json:"outfit_id"`
} `json:"result"`
}
if json.Unmarshal(b, &r) == nil && r.Result.OutfitID != "" {
if err := p.state.setOutfit(r.Result.OutfitID.String(), community); err != nil {
p.logf("products: remembering an outfit: %v", err)
}
}
return b, nil
}
return nil
}
x.ModifyResponse = func(resp *http.Response, b []byte) ([]byte, error) {
var r map[string]json.RawMessage
if json.Unmarshal(b, &r) != nil {
return b, nil
}
var outfits []map[string]json.RawMessage
if json.Unmarshal(r["result"], &outfits) != nil {
return b, nil
}
changed := false
for _, o := range outfits {
var id json.Number
if json.Unmarshal(o["user_outfits_id"], &id) != nil {
continue
}
products := p.state.outfit(id.String())
if len(products) == 0 {
continue
}
var pids []json.RawMessage
json.Unmarshal(o["pids"], &pids)
adult := false
for _, pid := range products {
if s := p.byProductID(pid); s != nil {
pids = append(pids, json.RawMessage(strconv.FormatInt(s.local, 10)))
adult = adult || s.pack.Manifest.Rating == "adult"
}
}
o["pids"], _ = json.Marshal(pids)
if adult {
var restricted string
json.Unmarshal(o["restricted"], &restricted)
if restricted == "" || restricted == "0" {
o["restricted"], _ = json.Marshal("1")
}
}
changed = true
}
if !changed {
return b, nil
}
r["result"], _ = json.Marshal(outfits)
return json.Marshal(r)
}
return nil
}