package products import ( "encoding/json" "errors" "net/http" "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 } switch path { 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, err := strconv.ParseInt(x.URL.Query().Get("pid"), 10, 64); err == nil && IsCommunity(id) { x.Response = localResponse(200, "application/json", []byte(`{"products":[]}`)) } case "/api/base_look.php": return p.interceptBaseLook(x) } return nil } 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) return p.addToLook(body) } case "test.inventoryProductBatch": return p.inventoryProductBatch(x, call) case "test.authorizeProducts2": return p.authorizeProducts2(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 { 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 { n, ok := v.(int64) if !ok { return nil, false } ids = append(ids, n) } 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": m.Creator, "products_name": 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) 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 }