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:
1 parent
c281fb572c
commit
ca7a86ce89
8 files changed
+225
-5
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user