The proxy serves products from local packs (t5de-api's format) under local IDs from 2,000,000,000: product info and permission answers (with the parent's derivation chain from IMVU), the product's CFL, and the shop's bundle lookup. Community products are taken out of outfit saves and outfit chat commands before IMVU sees them, remembered per account with the outfit they belong to, and added back when the client loads that outfit. Changed requests are re-signed with the session key from the login response. Co-Authored-By: Claude Opus 5.5 <[email protected]>
167 lines
4.1 KiB
Go
167 lines
4.1 KiB
Go
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 <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
|
|
}
|
|
|
|
// 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
|
|
}
|