Files
dhkatzandClaude Opus 5.5 6506c641f0
CI / build (push) Successful in 43s
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 <[email protected]>
2026-10-29 19:30:59 -07:00

188 lines
4.9 KiB
Go

package products
import (
"encoding/json"
"errors"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"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 <data dir>/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
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
// 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()
}
// 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) {
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 key, l := range s.data.IDs {
if l == local {
id, _, _ := strings.Cut(key, "@") // keys are "<product>@<version>" (versionKey)
return id, true
}
}
return "", false
}