package products import ( "encoding/json" "errors" "io/fs" "os" "path/filepath" "sort" "sync" ) // FirstLocalID is where the client's numbers for community products start. IMVU's product IDs are far below // (about 72 million in 2026), and the client's XML-RPC can't carry numbers past 2^31-1. const FirstLocalID = 2_000_000_000 // state is what the proxy remembers between runs, in /products.json: the local ID of every product it // has served (so outfits keep naming the same product), and the community products each IMVU account wears (IMVU // never sees them, so it can't remember them). type state struct { mu sync.Mutex path string data stateData } 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 } // wornOutfit is an account's outfit as last saved or sent: the IMVU products IMVU got, and the community products // taken out. The community products are added back only to that same outfit: if IMVU's outfit changed elsewhere // (another client, the website), they'd no longer fit it. type wornOutfit struct { IMVU []int64 `json:"imvu"` Products []string `json:"products"` } func loadState(path string) (*state, error) { s := &state{path: path, data: stateData{IDs: map[string]int64{}, Worn: map[string]wornOutfit{}}} b, err := os.ReadFile(path) if errors.Is(err, fs.ErrNotExist) { return s, nil } if err != nil { return nil, err } if err := json.Unmarshal(b, &s.data); err != nil { return nil, err } if s.data.IDs == nil { s.data.IDs = map[string]int64{} } if s.data.Worn == nil { s.data.Worn = map[string]wornOutfit{} } return s, nil } // localID is the product's local ID, given one if it has none yet. func (s *state) localID(product string) (int64, error) { s.mu.Lock() defer s.mu.Unlock() if id, ok := s.data.IDs[product]; ok { return id, nil } next := int64(FirstLocalID) for _, id := range s.data.IDs { next = max(next, id+1) } if next > 2_147_483_647 { return 0, errors.New("products: out of local IDs") } s.data.IDs[product] = next return next, s.saveLocked() } // worn is the community products the user wears with the IMVU outfit look, if they were recorded with it. func (s *state) worn(user string, look []int64) []string { s.mu.Lock() defer s.mu.Unlock() w, ok := s.data.Worn[user] if !ok || !sameSet(w.IMVU, look) { return nil } return append([]string(nil), w.Products...) } // setWorn records the user's outfit: the IMVU products IMVU got, and the community products taken out. func (s *state) setWorn(user string, imvu []int64, products []string) error { w := wornOutfit{IMVU: append([]int64(nil), imvu...), Products: append([]string(nil), products...)} sort.Slice(w.IMVU, func(i, j int) bool { return w.IMVU[i] < w.IMVU[j] }) sort.Strings(w.Products) s.mu.Lock() defer s.mu.Unlock() old, had := s.data.Worn[user] if had && sameSet(old.IMVU, w.IMVU) && equal(old.Products, w.Products) { return nil } if len(w.Products) == 0 { if !had { return nil } delete(s.data.Worn, user) } else { s.data.Worn[user] = w } 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) { return false } count := map[int64]int{} for _, v := range a { count[v]++ } for _, v := range b { if count[v] == 0 { return false } count[v]-- } return true } func equal(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } func (s *state) saveLocked() error { b, err := json.MarshalIndent(s.data, "", " ") if err != nil { return err } if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { return err } tmp := s.path + ".tmp" if err := os.WriteFile(tmp, b, 0o600); err != nil { return err } return os.Rename(tmp, s.path) } // productID is the product a local ID was given to. func (s *state) productID(local int64) (string, bool) { s.mu.Lock() defer s.mu.Unlock() for id, l := range s.data.IDs { if l == local { return id, true } } return "", false }