diff --git a/cmd/t5de-proxy/main.go b/cmd/t5de-proxy/main.go index 5c28e45..a4e6b45 100644 --- a/cmd/t5de-proxy/main.go +++ b/cmd/t5de-proxy/main.go @@ -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()) diff --git a/internal/fetch/fetch.go b/internal/fetch/fetch.go index 71c7c1c..4a0e818 100644 --- a/internal/fetch/fetch.go +++ b/internal/fetch/fetch.go @@ -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] +} diff --git a/internal/products/intercept.go b/internal/products/intercept.go index 5cc5488..a10859c 100644 --- a/internal/products/intercept.go +++ b/internal/products/intercept.go @@ -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 +} diff --git a/internal/products/products.go b/internal/products/products.go index 5e47f8d..ca977d2 100644 --- a/internal/products/products.go +++ b/internal/products/products.go @@ -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 diff --git a/internal/products/products_test.go b/internal/products/products_test.go index 8cfe440..8458812 100644 --- a/internal/products/products_test.go +++ b/internal/products/products_test.go @@ -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 " { + // 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) + } +} diff --git a/internal/products/room.go b/internal/products/room.go index 7535e0d..a44359a 100644 --- a/internal/products/room.go +++ b/internal/products/room.go @@ -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/") } 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) + } + } +} diff --git a/internal/products/state.go b/internal/products/state.go index 9901c47..b14e8b1 100644 --- a/internal/products/state.go +++ b/internal/products/state.go @@ -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 "@" (versionKey) return id, true } } diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index d19556d..7bedd87 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -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 +}