Files
t5de-proxy/internal/products/state.go
T
2026-10-29 02:49:10 -07:00

120 lines
2.8 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][]string `json:"worn"` // IMVU user ID -> product IDs
}
func loadState(path string) (*state, error) {
s := &state{path: path, data: stateData{IDs: map[string]int64{}, Worn: map[string][]string{}}}
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][]string{}
}
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.
func (s *state) worn(user string) []string {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string(nil), s.data.Worn[user]...)
}
// setWorn records the community products the user wears.
func (s *state) setWorn(user string, products []string) error {
products = append([]string(nil), products...)
sort.Strings(products)
s.mu.Lock()
defer s.mu.Unlock()
if equal(s.data.Worn[user], products) {
return nil
}
if len(products) == 0 {
delete(s.data.Worn, user)
} else {
s.data.Worn[user] = products
}
return s.saveLocked()
}
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)
}