CI / build (push) Successful in 43s
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]>
188 lines
4.9 KiB
Go
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
|
|
}
|