From 6506c641f0a39f017c5acc6a33fcd960fd6762c6 Mon Sep 17 00:00:00 2001 From: dhkatz Date: Thu, 29 Oct 2026 19:30:59 -0700 Subject: [PATCH] Keep community products out of saved outfits Saving an outfit (POST /api/outfits.php) sent its community products to IMVU. They're now taken out (any ID in the community range, known or not), the request re-signed, and the products remembered under the outfit's ID, then added back when the client loads its saved outfits (marked AP if one is adult). Co-Authored-By: Claude Opus 5.5 --- internal/products/intercept.go | 101 +++++++++++++++++++++++++++++ internal/products/products_test.go | 37 +++++++++++ internal/products/state.go | 23 ++++++- 3 files changed, 159 insertions(+), 2 deletions(-) diff --git a/internal/products/intercept.go b/internal/products/intercept.go index 40f8a2a..59caa73 100644 --- a/internal/products/intercept.go +++ b/internal/products/intercept.go @@ -47,6 +47,8 @@ func (p *Products) Intercept(x *proxy.Exchange) error { 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 } @@ -576,3 +578,102 @@ func (p *Products) interceptHide(x *proxy.Exchange) error { 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 + } + var products string + if json.Unmarshal(save["products"], &products) != nil { + 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, err := strconv.ParseInt(strings.TrimSpace(f), 10, 64) + if err == nil && 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 { + 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 +} diff --git a/internal/products/products_test.go b/internal/products/products_test.go index 8458812..6bd1410 100644 --- a/internal/products/products_test.go +++ b/internal/products/products_test.go @@ -73,6 +73,13 @@ func (f *fakeIMVU) RoundTrip(r *http.Request) (*http.Response, error) { return reply(`{"status": "success"}`) } switch r.URL.Path { + case "/api/outfits.php": + if len(body) > 0 { + checkSigned(false) + f.record("save outfit", body) + return reply(`{"result": {"outfit_id": 555}}`) + } + return reply(`{"result": [{"user_outfits_id": "555", "pids": [80, 1], "restricted": "0"}, {"user_outfits_id": "9", "pids": [80]}]}`) case "/api/xmlrpc/chat.php": f.record("chat", body) return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{xmlrpc.Struct{ @@ -361,3 +368,33 @@ func TestInventory(t *testing.T) { t.Fatalf("product of the new local ID: %q", id) } } + +func TestSavedOutfits(t *testing.T) { + h, _ := newHarness(t) + local := h.p.byProductID("blue-coat").local + h.do("POST", "https://secure.imvu.com/api/xmlrpc/client.php", call("test.avatarInfoForLogin2"), false, false) + + // Saving: IMVU gets the outfit without the community product. + // (2100000000 is a community ID the proxy doesn't know: it's kept from IMVU all the same.) + save := []byte(fmt.Sprintf(`{"outfit_name": "Mine", "products": "80,1,%d,2100000000", "privacy": 1}`, local)) + if status, _ := h.do("POST", "https://client-dynamic.imvu.com/api/outfits.php", save, true, false); status != 200 { + t.Fatalf("status %d", status) + } + if got := h.imvu.asked["save outfit"]; len(got) != 1 || !strings.Contains(got[0], `"products":"80,1"`) { + t.Fatalf("IMVU got %q", got) + } + // Loading saved outfits: it's back in that outfit only. + _, body := h.do("GET", "https://client-dynamic.imvu.com/api/outfits.php?batch=555,9", nil, false, false) + var r struct { + Result []struct { + ID string `json:"user_outfits_id"` + Pids []int64 `json:"pids"` + } `json:"result"` + } + if err := json.Unmarshal(body, &r); err != nil { + t.Fatal(err, string(body)) + } + if fmt.Sprint(r.Result[0].Pids) != fmt.Sprint([]int64{80, 1, local}) || fmt.Sprint(r.Result[1].Pids) != "[80]" { + t.Fatalf("outfits %s", body) + } +} diff --git a/internal/products/state.go b/internal/products/state.go index b14e8b1..22dc588 100644 --- a/internal/products/state.go +++ b/internal/products/state.go @@ -25,8 +25,9 @@ type state struct { } type stateData struct { - IDs map[string]int64 `json:"ids"` // product ID -> local ID - Worn map[string]wornOutfit `json:"worn"` // IMVU user ID -> what they wear + IDs map[string]int64 `json:"ids"` // product ID -> local ID + Worn map[string]wornOutfit `json:"worn"` // IMVU user ID -> what they wear + Outfits map[string][]string `json:"outfits,omitempty"` // saved outfit ID -> its community products } // wornOutfit is an account's outfit as last saved or sent: the IMVU products IMVU got, and the community products @@ -109,6 +110,24 @@ func (s *state) setWorn(user string, imvu []int64, products []string) error { return s.saveLocked() } +// outfit is the community products saved with an outfit (by IMVU's outfit ID). +func (s *state) outfit(id string) []string { + s.mu.Lock() + defer s.mu.Unlock() + return append([]string(nil), s.data.Outfits[id]...) +} + +// setOutfit remembers the community products saved with an outfit. +func (s *state) setOutfit(id string, products []string) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.data.Outfits == nil { + s.data.Outfits = map[string][]string{} + } + s.data.Outfits[id] = append([]string(nil), products...) + return s.saveLocked() +} + // sameSet reports whether a and b hold the same IDs, in any order. func sameSet(a, b []int64) bool { if len(a) != len(b) {