T5 products in the client's inventory
CI / build (push) Successful in 25s

The inventory's product list (test.showInventory) gets the community products the person has: local packs, and
hub products in their library. Hiding one is answered by the proxy (and removes a hub product from the library).
When the library changes, the client is told to load its inventory again, with the server event IMVU sends
after a purchase. A product whose revision or info changes gets a new local ID, since the client keeps product
info for good; names lose characters the client's pages could take as HTML.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-29 18:59:52 -07:00
1 parent c281fb572c
commit ca7a86ce89
8 files changed
+225 -5

No files matched your search

+11
View File
@@ -145,7 +145,18 @@ func run(logger *log.Logger, listen, ready string, parentPID int, trace bool, pa
prods.Observer = client
client.OnChat = prods.Others
fetcher.OnReady = prods.Ready
fetcher.OnLibrary = prods.RefreshInventory
prods.Want = fetcher.Want
prods.HubDir, prods.Owned = hubProducts, fetcher.Owned
prods.OnHide = func(id string) {
if fetch.IsHubID(id) {
go func() {
if err := client.Call(context.Background(), "DELETE", "/api/v1/library/"+id, nil, nil); err != nil {
logger.Printf("hub: removing %s from the library: %v", id, err)
}
}()
}
}
}
client.OnLibrary = func() { fetcher.SyncLibrary(context.Background()) }
go client.Run(context.Background())
+22
View File
@@ -42,8 +42,11 @@ type Fetcher struct {
// OnReady, if set, is called when a product was written (or updated) in the cache.
OnReady func(id string)
// OnLibrary, if set, is called after the library was read and its products fetched.
OnLibrary func()
mu sync.Mutex
library map[string]bool // the person's library, as last read
inflight map[string]bool
failed map[string]time.Time // products that failed lately (not retried for a while)
access string
@@ -69,6 +72,18 @@ func (f *Fetcher) SyncLibrary(ctx context.Context) {
f.logf("fetch: reading the library: %v", err)
return
}
ids := map[string]bool{}
for _, e := range lib {
ids[e.Product.ID] = true
}
f.mu.Lock()
f.library = ids
f.mu.Unlock()
defer func() {
if f.OnLibrary != nil {
f.OnLibrary()
}
}()
for _, e := range lib {
if err := f.Ensure(ctx, e.Product.ID); err != nil {
f.logf("fetch: %s (%s): %v", e.Product.ID, e.Product.Name, err)
@@ -291,3 +306,10 @@ func copyFile(src, dst string) error {
}
return out.Close()
}
// Owned reports whether a product is in the person's library (as last read).
func (f *Fetcher) Owned(id string) bool {
f.mu.Lock()
defer f.mu.Unlock()
return f.library[id]
}
+87 -2
View File
@@ -4,6 +4,9 @@ import (
"encoding/json"
"errors"
"net/http"
"net/url"
"path/filepath"
"sort"
"strconv"
"strings"
@@ -42,6 +45,8 @@ func (p *Products) Intercept(x *proxy.Exchange) error {
}
case "/api/base_look.php":
return p.interceptBaseLook(x)
case "/api/service/inventory/inventory_delete.php":
return p.interceptHide(x)
}
return nil
}
@@ -66,6 +71,10 @@ func (p *Products) interceptXMLRPC(x *proxy.Exchange) error {
return p.inventoryProductBatch(x, call)
case "test.authorizeProducts2":
return p.authorizeProducts2(x, call)
case "test.showInventory":
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
return p.addToInventory(body)
}
}
return nil
}
@@ -241,8 +250,8 @@ func (s *served) productInfo(parent xmlrpc.Struct) xmlrpc.Struct {
own := map[string]xmlrpc.Value{
"products_id": s.local,
"manufacturers_id": int64(0),
"manufacturers_name": m.Creator,
"products_name": m.Name,
"manufacturers_name": plain(m.Creator),
"products_name": plain(m.Name),
"products_mature": mature,
"products_bundle": "",
"is_published": "1",
@@ -486,3 +495,79 @@ func (p *Products) Outgoing(queue, mount string, message []byte) ([]byte, bool)
p.logf("products: kept %d community products out of a %s sent to IMVU", len(r.products), command)
return m.withText(r.text), false
}
// plain keeps a name to characters the client's pages can show however they insert it: some insert creator and
// product names as HTML, in pages that can call into the client.
func plain(s string) string {
return strings.Map(func(r rune) rune {
switch r {
case '<', '>', '&', '"', '\'', '`':
return -1
}
return r
}, s)
}
// inventoryLocals is the community products in the inventory: local packs, and hub products in the library.
func (p *Products) inventoryLocals() []int64 {
p.refresh(false)
p.mu.Lock()
defer p.mu.Unlock()
var ids []int64
for id, s := range p.byID {
if p.HubDir != "" && filepath.Dir(s.dir) == filepath.Clean(p.HubDir) && (p.Owned == nil || !p.Owned(id)) {
continue
}
ids = append(ids, s.local)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}
// addToInventory adds the community products to test.showInventory's answer (a list of product IDs).
func (p *Products) addToInventory(body []byte) ([]byte, error) {
r, err := xmlrpc.ParseResponse(body)
if err != nil || r.Fault != nil || len(r.Params) != 1 {
return body, nil
}
list, ok := r.Params[0].([]xmlrpc.Value)
if !ok {
return body, nil
}
have := map[int64]bool{}
for _, v := range list {
if n, ok := v.(int64); ok {
have[n] = true
}
}
added := 0
for _, id := range p.inventoryLocals() {
if !have[id] {
list = append(list, id)
added++
}
}
if added == 0 {
return body, nil
}
r.Params[0] = list
p.logf("products: %d community products in the inventory", added)
return r.Marshal(), nil
}
// interceptHide answers hiding a community product in the inventory itself (IMVU doesn't know it).
func (p *Products) interceptHide(x *proxy.Exchange) error {
form, err := url.ParseQuery(string(x.Body))
if err != nil {
return nil
}
pid, err := strconv.ParseInt(form.Get("pid"), 10, 64)
if err != nil || !IsCommunity(pid) {
return nil
}
if id, ok := p.state.productID(pid); ok && p.OnHide != nil {
p.OnHide(id)
}
x.Response = localResponse(200, "application/json", []byte(`{"success": true}`))
return nil
}
+17 -1
View File
@@ -50,6 +50,13 @@ type Products struct {
// Want, if set, is asked for products someone in a room wears that aren't here (the fetcher gets hub products).
Want func(id string)
// HubDir is the pack root of products from the hub; of those, only ones Owned says are in the person's library
// are in the inventory (the rest are there because someone in a room wears them). Local packs always are.
HubDir string
Owned func(id string) bool
// OnHide, if set, hears that the person hid a community product in the inventory (removing it from their
// library, for hub products).
OnHide func(id string)
roomMu sync.Mutex
room room
@@ -204,7 +211,7 @@ func (p *Products) loadLocked(dir, fp string) (*served, error) {
if err != nil {
return nil, err
}
local, err := p.state.localID(pk.Manifest.ID)
local, err := p.state.localID(versionKey(pk))
if err != nil {
return nil, err
}
@@ -224,6 +231,15 @@ func (p *Products) loadLocked(dir, fp string) (*served, error) {
return &served{dir: dir, fingerprint: fp, pack: pk, local: local, cfl: buf.Bytes(), checksum: hex.EncodeToString(sum[:])}, nil
}
// versionKey names a version of a product for its local ID: the revision and the info the client shows. The client
// keeps product info for good (its product info cache), so a product whose files or info changed gets a new local
// ID, and with it fresh info.
func versionKey(pk *pack.Pack) string {
m := pk.Manifest
sum := md5.Sum([]byte(strings.Join([]string{m.Name, m.Creator, m.Rating}, "|")))
return fmt.Sprintf("%s@%d.%s", m.ID, m.Revision, hex.EncodeToString(sum[:4]))
}
// fingerprint changes when anything in the pack directory (or its files/ directory) changes.
func fingerprint(dir string) string {
var b strings.Builder
+47 -1
View File
@@ -99,6 +99,8 @@ func (f *fakeIMVU) RoundTrip(r *http.Request) (*http.Response, error) {
return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{xmlrpc.Struct{
{Name: "userId", Value: int64(7)}, {Name: "securityKey", Value: key},
{Name: "look", Value: []xmlrpc.Value{int64(80), int64(1)}}}}}).Marshal()))
case "test.showInventory":
return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{[]xmlrpc.Value{int64(1), int64(2)}}}).Marshal()))
case "test.inventoryProductBatch":
checkSigned(false)
var out []xmlrpc.Value
@@ -200,7 +202,8 @@ func TestCommunityProductEndToEnd(t *testing.T) {
if id, _ := ours.Get("products_id"); id != local {
t.Fatalf("entry %v", ours)
}
if name, _ := ours.Get("products_name"); name != "Blue <Coat>" {
// Names lose characters the client's pages could take as HTML.
if name, _ := ours.Get("products_name"); name != "Blue Coat" {
t.Fatalf("name %v", name)
}
if path, _ := ours.Get("cPath"); path != "106_40" {
@@ -315,3 +318,46 @@ func TestPackChangesAreServed(t *testing.T) {
t.Fatal("a removed pack is still served")
}
}
func TestInventory(t *testing.T) {
h, packs := newHarness(t)
hubDir := t.TempDir()
writePack(t, hubDir, "abcdefgh23", 1) // a hub product, kept by the fetcher
writePack(t, hubDir, "zzzzzzzz22", 1) // one someone in a room wore, not in the library
h.p.dirs = append(h.p.dirs, hubDir)
h.p.HubDir = hubDir
h.p.Owned = func(id string) bool { return id == "abcdefgh23" }
var hidden []string
h.p.OnHide = func(id string) { hidden = append(hidden, id) }
h.p.refresh(true)
local, hubLocal := h.p.byProductID("blue-coat").local, h.p.byProductID("abcdefgh23").local
api := "http://client-dynamic.imvu.com/api/xmlrpc/client.php"
_, body := h.do("POST", api, call("test.showInventory", int64(7), []xmlrpc.Value{int64(495)}, false), true, false)
r, err := xmlrpc.ParseResponse(body)
if err != nil {
t.Fatal(err)
}
got := fmt.Sprint(r.Params[0])
if want := fmt.Sprint([]xmlrpc.Value{int64(1), int64(2), local, hubLocal}); got != want {
t.Fatalf("inventory %s, want %s", got, want)
}
// Hiding a community product is answered here (and for hub products, takes them out of the library).
status, body := h.do("POST", "http://client-dynamic.imvu.com/api/service/inventory/inventory_delete.php",
[]byte(fmt.Sprintf("action=delete&cid=7&pid=%d", hubLocal)), true, false)
if status != 200 || !strings.Contains(string(body), `"success": true`) || len(hidden) != 1 || hidden[0] != "abcdefgh23" {
t.Fatalf("hide: %d %s %v", status, body, hidden)
}
// A product whose info changes gets a new local ID, so the client's product info cache shows the new info.
dir := filepath.Join(packs, "blue-coat")
manifest := `{"format": 1, "id": "blue-coat", "revision": 1, "name": "Red Coat", "creator": "Tester", "rating": "general"}`
os.WriteFile(filepath.Join(dir, "t5de-product.json"), []byte(manifest), 0o644)
h.p.refresh(true)
if again := h.p.byProductID("blue-coat").local; again == local {
t.Fatal("same local ID after a rename")
} else if id, _ := h.p.state.productID(again); id != "blue-coat" {
t.Fatalf("product of the new local ID: %q", id)
}
}
+23
View File
@@ -31,6 +31,7 @@ type room struct {
userIDs map[int64]string // user -> the gateway's user_id for them, as last seen
missing map[string]bool // products others wear that aren't in the packs (logged once)
last map[string][]hub.Avatar // chat -> what the hub last said about the others in it
users map[*proxy.IMQConn]string // each connection's user queue ("/user/<id>")
}
func (r *room) init() {
@@ -41,6 +42,7 @@ func (r *room) init() {
r.userIDs = map[int64]string{}
r.missing = map[string]bool{}
r.last = map[string][]hub.Avatar{}
r.users = map[*proxy.IMQConn]string{}
}
}
@@ -70,6 +72,9 @@ func (p *Products) Subscribed(c *proxy.IMQConn, queues []string) {
var joined []string
var rooms []string
for _, q := range queues {
if strings.HasPrefix(q, "/user/") {
p.room.users[c] = q
}
chat := chatOf(q)
if chat == "" {
continue
@@ -124,6 +129,7 @@ func (p *Products) Closed(c *proxy.IMQConn) {
p.Unsubscribed(c, queues)
p.roomMu.Lock()
delete(p.room.subs, c)
delete(p.room.users, c)
p.roomMu.Unlock()
}
@@ -358,3 +364,20 @@ func (p *Products) interceptParticipant(x *proxy.Exchange) error {
x.Body = body
return nil
}
// RefreshInventory has the client load its inventory again (after the library changed), with the server event
// IMVU sends after a purchase: "refreshInventory {}" on the user queue's web_msg mount.
func (p *Products) RefreshInventory() {
p.roomMu.Lock()
p.room.init()
users := make(map[*proxy.IMQConn]string, len(p.room.users))
for c, q := range p.room.users {
users[c] = q
}
p.roomMu.Unlock()
for c, q := range users {
if err := c.Deliver(imq.Delivered{UserID: "0", Queue: q, Mount: "web_msg", Message: []byte("refreshInventory {}")}); err != nil {
p.logf("products: refreshing the inventory: %v", err)
}
}
}
+3 -1
View File
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"sort"
"strings"
"sync"
)
@@ -157,8 +158,9 @@ func (s *state) saveLocked() error {
func (s *state) productID(local int64) (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for id, l := range s.data.IDs {
for key, l := range s.data.IDs {
if l == local {
id, _, _ := strings.Cut(key, "@") // keys are "<product>@<version>" (versionKey)
return id, true
}
}
+15
View File
@@ -374,3 +374,18 @@ func copyFlushing(w http.ResponseWriter, body io.Reader) (int64, error) {
func since(t time.Time) string {
return time.Since(t).Round(time.Millisecond).String()
}
// Interceptors runs several Interceptors in order, stopping at the first that answers the request itself.
type Interceptors []Interceptor
func (is Interceptors) Intercept(x *Exchange) error {
for _, i := range is {
if err := i.Intercept(x); err != nil {
return err
}
if x.Response != nil {
return nil
}
}
return nil
}