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-[/]). 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) } case "test.setRoomState": if len(call.Params) == 1 { return p.interceptSetRoomState(x, call) } case "test.getRoomState": if len(call.Params) == 1 { return p.interceptGetRoomState(x, call) } } 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") } all := append(rows, ours(columns)...) r.Params[0] = all 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) { out, drop := p.outgoing(queue, mount, message) // A hosted room's message goes to the hub (without community products, as to IMVU), never to IMVU. if p.Hosted != nil && p.Hosted.Send(queue, mount, out, message, drop) { return nil, true } return out, drop } func (p *Products) outgoing(queue, mount string, message []byte) ([]byte, bool) { if strings.HasPrefix(queue, "/chat/") && mount == "control" { if out, changed := p.outgoingControlScene(message); changed { return out, false } return message, false } if !strings.HasPrefix(queue, "/chat/") || mount != "messages" { return message, false } m, ok := parseChatMessage(message) if !ok { return message, false } if strings.HasPrefix(m.text, setRoomStateCommand) { if out, changed := p.outgoingScene(m); changed { return out, false } 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 }