diff --git a/cmd/t5de-proxy/main.go b/cmd/t5de-proxy/main.go index 75018b3..6617f0c 100644 --- a/cmd/t5de-proxy/main.go +++ b/cmd/t5de-proxy/main.go @@ -153,7 +153,7 @@ func run(logger *log.Logger, listen, ready string, parentPID int, trace bool, pa fetcher.OnLibrary = prods.RefreshInventory prods.Want = fetcher.Want prods.HubDir, prods.Owned = hubProducts, fetcher.Owned - prods.Shop, prods.Fetch = client.Products, fetcher.Ensure + prods.Shop, prods.Fetch, prods.Library = client.Products, fetcher.Ensure, fetcher.Library prods.AddToLibrary = func(ctx context.Context, id string) error { return client.Call(ctx, "PUT", "/api/v1/library/"+id, map[string]any{}, nil) } diff --git a/internal/fetch/fetch.go b/internal/fetch/fetch.go index 4a0e818..f01f5cd 100644 --- a/internal/fetch/fetch.go +++ b/internal/fetch/fetch.go @@ -47,6 +47,7 @@ type Fetcher struct { mu sync.Mutex library map[string]bool // the person's library, as last read + entries []hub.LibraryEntry inflight map[string]bool failed map[string]time.Time // products that failed lately (not retried for a while) access string @@ -77,7 +78,7 @@ func (f *Fetcher) SyncLibrary(ctx context.Context) { ids[e.Product.ID] = true } f.mu.Lock() - f.library = ids + f.library, f.entries = ids, lib f.mu.Unlock() defer func() { if f.OnLibrary != nil { @@ -313,3 +314,10 @@ func (f *Fetcher) Owned(id string) bool { defer f.mu.Unlock() return f.library[id] } + +// Library is the person's library, as last read. +func (f *Fetcher) Library() []hub.LibraryEntry { + f.mu.Lock() + defer f.mu.Unlock() + return append([]hub.LibraryEntry(nil), f.entries...) +} diff --git a/internal/hub/hub.go b/internal/hub/hub.go index acc0c03..a098b68 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -463,11 +463,9 @@ type Files struct { // LibraryEntry is a product in the person's library. type LibraryEntry struct { - Product struct { - ID string `json:"id"` - Name string `json:"name"` - } `json:"product"` - StayOn *int `json:"stayOn"` + Product Product `json:"product"` + StayOn *int `json:"stayOn"` + AddedAt time.Time `json:"addedAt"` } // Library is the person's library. @@ -503,6 +501,7 @@ type Product struct { Parent int64 `json:"parent"` Image string `json:"image"` Categories []Category `json:"categories"` // the parent's IMVU category path, top first + CreatedAt time.Time `json:"createdAt"` } // Category is an IMVU shop category. diff --git a/internal/products/intercept.go b/internal/products/intercept.go index 5a663d8..05a6582 100644 --- a/internal/products/intercept.go +++ b/internal/products/intercept.go @@ -54,6 +54,12 @@ func (p *Products) Intercept(x *proxy.Exchange) error { } else if len(x.Body) > 0 { return p.interceptProductInfo(x) } + case "/api/outfit_products.php": + return p.interceptOutfitProducts(x) + case "/api/find_bundle.php": + return p.interceptFindBundle(x) + case "/api/recently_added_inventory.php": + return p.interceptRecent(x) case "/api/shop/search_st.php": return p.interceptShopSearch(x) case "/api/service/shop/svc_catalog_purchase.php": @@ -625,8 +631,20 @@ func (p *Products) interceptOutfits(x *proxy.Exchange) error { if json.Unmarshal(x.Body, &save) != nil { return nil } + removed := p.outfitRemovals(save) // the outfit card's remove_pids + resave := func() error { + body, _ := json.Marshal(save) + if !p.keys.resign(x.Request.Header, x.Body, x.URL.RawQuery, body, x.URL.RawQuery) { + return errNoKey + } + x.Body = body + return nil + } var products string if json.Unmarshal(save["products"], &products) != nil { + if removed { + return resave() + } return nil // not a save (e.g. set_default_outfit_id) } var kept []string @@ -644,6 +662,9 @@ func (p *Products) interceptOutfits(x *proxy.Exchange) error { kept = append(kept, f) } if stripped == 0 { + if removed { + return resave() + } return nil } save["products"], _ = json.Marshal(strings.Join(kept, ",")) diff --git a/internal/products/outfitcard.go b/internal/products/outfitcard.go new file mode 100644 index 0000000..18d8a85 --- /dev/null +++ b/internal/products/outfitcard.go @@ -0,0 +1,175 @@ +package products + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "git.t5de.dev/t5de/t5de-proxy/internal/hub" + "git.t5de.dev/t5de/t5de-proxy/internal/proxy" +) + +// The client's outfit card (dialogs/outfit_card) lists an outfit's products from api/outfit_products.php, asks +// api/find_bundle.php whether the outfit's products are a bundle on sale, and saves changes (removing products with +// remove_pids) to api/outfits.php. IMVU only has the outfit without its community products (see interceptOutfits), +// so the proxy adds them to the list, and keeps them out of the rest. + +// interceptOutfitProducts adds an outfit's community products to its product list. +func (p *Products) interceptOutfitProducts(x *proxy.Exchange) error { + outfit := x.URL.Query().Get("outfit_id") + products := p.state.outfit(outfit) + if outfit == "" || len(products) == 0 { + return nil + } + x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) { + var r map[string]json.RawMessage + if json.Unmarshal(body, &r) != nil { + return body, nil + } + var list map[string]json.RawMessage + if json.Unmarshal(r["result"], &list) != nil { + if string(r["result"]) != "[]" { // PHP's empty object + p.logf("products: outfit_products.php: unexpected result") + return body, nil + } + list = map[string]json.RawMessage{} + } + for _, id := range products { + if row, ok := p.outfitCardRow(id); ok { + list[strconv.FormatInt(row.local, 10)], _ = json.Marshal(row.entry()) + } + } + r["result"], _ = json.Marshal(list) + return json.Marshal(r) + } + return nil +} + +type cardRow struct { + hub.Product + local int64 +} + +// entry is the outfit card's product entry. Community products can't be in a bundle for sale on IMVU. +func (r cardRow) entry() map[string]any { + category, categoryID := "", int64(0) + if n := len(r.Categories); n > 0 { + category, categoryID = categoryText(r.Categories[n-1].Name), r.Categories[n-1].ID + } + ap := 0 + if r.Rating == "adult" { + ap = 1 + } + return map[string]any{"name": plain(r.Name), "image": r.Image, "category_id": categoryID, "category": category, + "is_ap": ap, "is_ap_plus": 0, "is_ufi": 0, "is_bundleable": 0} +} + +// outfitCardRow is a community product as the outfit card shows it: from the library or the shop's listing if the +// hub listed it, else from its pack. +func (p *Products) outfitCardRow(id string) (cardRow, bool) { + s := p.byProductID(id) + if s == nil { + return cardRow{}, false + } + if p.Library != nil { + for _, e := range p.Library() { + if e.Product.ID == id { + return cardRow{e.Product, s.local}, true + } + } + } + m := s.pack.Manifest + p.mu.Lock() + hp, ok := p.shopSeen[id] + p.mu.Unlock() + if !ok { + hp = hub.Product{ID: id, Name: m.Name, Creator: m.Creator, Rating: m.Rating} + } + return cardRow{hp, s.local}, true +} + +// interceptFindBundle asks IMVU about the outfit's IMVU products only (find_bundle.php?pids=1,2,...). +func (p *Products) interceptFindBundle(x *proxy.Exchange) error { + parts := strings.Split(x.URL.RawQuery, "&") + changed := false + for i, part := range parts { + name, value, ok := strings.Cut(part, "=") + if !ok || name != "pids" { + continue + } + var kept []string + for _, f := range strings.Split(value, ",") { + if id, ok := parseID(f); ok && IsCommunity(id) { + changed = true + continue + } + kept = append(kept, f) + } + parts[i] = name + "=" + strings.Join(kept, ",") + } + if !changed { + return nil + } + query := strings.Join(parts, "&") + if !p.keys.resign(x.Request.Header, x.Body, x.URL.RawQuery, x.Body, query) { + return errNoKey + } + x.URL.RawQuery = query + x.Request.URL.RawQuery = query + return nil +} + +// outfitRemovals takes community products out of an outfit card save's remove_pids, and out of the outfit as +// remembered here. It reports whether the body changed. +func (p *Products) outfitRemovals(save map[string]json.RawMessage) bool { + var remove []json.RawMessage + if json.Unmarshal(save["remove_pids"], &remove) != nil { + return false + } + var kept []json.RawMessage + var gone []string + for _, raw := range remove { + if id, ok := rawID(raw); ok && IsCommunity(id) { + if pid, ok := p.state.productID(id); ok { + gone = append(gone, pid) + } + continue + } + kept = append(kept, raw) + } + if len(kept) == len(remove) { + return false + } + if kept == nil { + kept = []json.RawMessage{} + } + save["remove_pids"], _ = json.Marshal(kept) + var outfit json.Number + if json.Unmarshal(save["outfit_id"], &outfit) != nil { + var s string + json.Unmarshal(save["outfit_id"], &s) + outfit = json.Number(s) + } + if outfit != "" && len(gone) > 0 { + var left []string + for _, pid := range p.state.outfit(outfit.String()) { + if !contains(gone, pid) { + left = append(left, pid) + } + } + if err := p.state.setOutfit(outfit.String(), left); err != nil { + p.logf("products: remembering an outfit: %v", err) + } + } + return true +} + +func contains(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} diff --git a/internal/products/outfitcard_test.go b/internal/products/outfitcard_test.go new file mode 100644 index 0000000..22c3474 --- /dev/null +++ b/internal/products/outfitcard_test.go @@ -0,0 +1,42 @@ +package products + +import ( + "encoding/json" + "fmt" + "strings" + "testing" +) + +func TestOutfitCard(t *testing.T) { + h, _ := newHarness(t) + local := h.p.byProductID("blue-coat").local + h.do("POST", "https://secure.imvu.com/api/xmlrpc/client.php", call("test.avatarInfoForLogin2"), false, false) + save := []byte(fmt.Sprintf(`{"outfit_name": "Mine", "products": "80,1,%d"}`, local)) + h.do("POST", "https://client-dynamic.imvu.com/api/outfits.php", save, true, false) + + // The product list has the community product too, not for sale. + _, body := h.do("GET", "https://client-dynamic.imvu.com/api/outfit_products.php?outfit_id=555", nil, true, true) + var r struct{ Result map[string]map[string]any } + if json.Unmarshal(body, &r) != nil || len(r.Result) != 2 { + t.Fatalf("products %s", body) + } + if e := r.Result[fmt.Sprint(local)]; e == nil || e["name"] != "Blue Coat" || e["is_bundleable"] != float64(0) { + t.Fatalf("community entry %v", e) + } + + // The bundle check is asked about IMVU's products only (re-signed). + h.do("GET", fmt.Sprintf("https://client-dynamic.imvu.com/api/find_bundle.php?pids=80,%d.0,1", local), nil, true, true) + if got := h.imvu.asked["find bundle"]; len(got) != 1 || got[0] != "pids=80,1" { + t.Fatalf("find_bundle: IMVU got %q", got) + } + + // Removing it in the card: IMVU hears of IMVU's removals only, and the outfit forgets it. + h.do("POST", "https://client-dynamic.imvu.com/api/outfits.php", + []byte(fmt.Sprintf(`{"outfit_id": "555", "outfit_name": "Mine", "remove_pids": [1, %d]}`, local)), true, false) + if got := h.imvu.asked["save outfit"]; len(got) != 2 || !strings.Contains(got[1], `"remove_pids":[1]`) { + t.Fatalf("IMVU got %q", got) + } + if left := h.p.state.outfit("555"); len(left) != 0 { + t.Fatalf("outfit still has %v", left) + } +} diff --git a/internal/products/products.go b/internal/products/products.go index dddcca4..fd4bb77 100644 --- a/internal/products/products.go +++ b/internal/products/products.go @@ -64,6 +64,8 @@ type Products struct { Shop func(ctx context.Context, query string) ([]hub.Product, error) Fetch func(ctx context.Context, id string) error AddToLibrary func(ctx context.Context, id string) error + // Library, if set, is the person's hub library as last read (for the inventory's Recently Added). + Library func() []hub.LibraryEntry roomMu sync.Mutex room room diff --git a/internal/products/products_test.go b/internal/products/products_test.go index 530ce76..c397648 100644 --- a/internal/products/products_test.go +++ b/internal/products/products_test.go @@ -73,6 +73,14 @@ func (f *fakeIMVU) RoundTrip(r *http.Request) (*http.Response, error) { return reply(`{"status": "success"}`) } switch r.URL.Path { + case "/api/outfit_products.php": + return reply(`{"result": {"80": {"pid": 80, "name": "Body", "is_bundleable": true}}, "bundle_id": 0}`) + case "/api/find_bundle.php": + checkSigned(true) + f.record("find bundle", []byte(r.URL.RawQuery)) + return reply(`{"result": 0}`) + case "/api/recently_added_inventory.php": + return reply(`[31162856, 108363]`) case "/api/shop/product.php": checkSigned(false) f.record("product info", body) diff --git a/internal/products/recent.go b/internal/products/recent.go new file mode 100644 index 0000000..b5648a6 --- /dev/null +++ b/internal/products/recent.go @@ -0,0 +1,78 @@ +package products + +import ( + "encoding/json" + "net/http" + "sort" + "strconv" + "time" + + "git.t5de.dev/t5de/t5de-proxy/internal/hub" + "git.t5de.dev/t5de/t5de-proxy/internal/proxy" +) + +// The inventory's Recently Added (api/recently_added_inventory.php?type=clothing|furni|scenes) is a list of product +// IDs from IMVU; T5 products the person added to their library lately go first, newest first. + +// recentFor is how long a T5 product counts as recently added. +const recentFor = 30 * 24 * time.Hour + +// IMVU's categories for furniture and rooms (the inventory's FURNI_CATEGORIES and ROOM_CATEGORIES, and rooms' 107). +var ( + furniCategories = map[int64]bool{1027: true, 110: true} + roomCategories = map[int64]bool{107: true, 109: true, 366: true, 626: true, 2386: true} +) + +// inventoryType is which Recently Added list a product belongs in: clothing (anything worn), furni or scenes. +func inventoryType(categories []hub.Category) string { + for _, c := range categories { + if furniCategories[c.ID] { + return "furni" + } + } + for _, c := range categories { + if roomCategories[c.ID] { + return "scenes" + } + } + return "clothing" +} + +func (p *Products) interceptRecent(x *proxy.Exchange) error { + if p.Library == nil { + return nil + } + kind := x.URL.Query().Get("type") + x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) { + var ids []json.RawMessage + if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &ids) != nil { + return body, nil + } + ours := p.recentLocals(kind, time.Now()) + if len(ours) == 0 { + return body, nil + } + out := make([]json.RawMessage, 0, len(ours)+len(ids)) + for _, local := range ours { + out = append(out, json.RawMessage(strconv.FormatInt(local, 10))) + } + return json.Marshal(append(out, ids...)) + } + return nil +} + +// recentLocals is the local IDs of T5 products of that kind added to the library within recentFor, newest first. +func (p *Products) recentLocals(kind string, now time.Time) []int64 { + lib := p.Library() + sort.SliceStable(lib, func(i, j int) bool { return lib[i].AddedAt.After(lib[j].AddedAt) }) + var out []int64 + for _, e := range lib { + if now.Sub(e.AddedAt) > recentFor || inventoryType(e.Product.Categories) != kind { + continue + } + if s := p.byProductID(e.Product.ID); s != nil && p.inHubDir(e.Product.ID) { + out = append(out, s.local) + } + } + return out +} diff --git a/internal/products/recent_test.go b/internal/products/recent_test.go new file mode 100644 index 0000000..4a4f691 --- /dev/null +++ b/internal/products/recent_test.go @@ -0,0 +1,84 @@ +package products + +import ( + "encoding/json" + "fmt" + "testing" + "time" + + "git.t5de.dev/t5de/t5de-proxy/internal/hub" +) + +func TestRecentlyAdded(t *testing.T) { + h, _ := newHarness(t) + hubDir := t.TempDir() + for _, id := range []string{"coatcoat22", "sofasofa22", "oldoldold2", "hairhair22"} { + writePack(t, hubDir, id, 1) + } + h.p.dirs = append(h.p.dirs, hubDir) + h.p.HubDir = hubDir + h.p.Owned = func(string) bool { return true } + h.p.refresh(true) + now := time.Now() + clothes := []hub.Category{{ID: 106}, {ID: 40}, {ID: 716}} + h.p.Library = func() []hub.LibraryEntry { + return []hub.LibraryEntry{ + {Product: hub.Product{ID: "coatcoat22", Categories: clothes}, AddedAt: now.Add(-2 * time.Hour)}, + {Product: hub.Product{ID: "sofasofa22", Categories: []hub.Category{{ID: 1027}, {ID: 1056}}}, AddedAt: now.Add(-time.Hour)}, + {Product: hub.Product{ID: "oldoldold2", Categories: clothes}, AddedAt: now.Add(-40 * 24 * time.Hour)}, + {Product: hub.Product{ID: "hairhair22"}, AddedAt: now.Add(-time.Hour)}, // no categories known: worn + } + } + local := func(id string) int64 { return h.p.byProductID(id).local } + get := func(kind string) []int64 { + _, b := h.do("GET", "http://client-dynamic.imvu.com/api/recently_added_inventory.php?type="+kind, nil, false, false) + var ids []int64 + if json.Unmarshal(b, &ids) != nil { + t.Fatalf("%s: %s", kind, b) + } + return ids + } + if got, want := fmt.Sprint(get("clothing")), fmt.Sprint([]int64{local("hairhair22"), local("coatcoat22"), 31162856, 108363}); got != want { + t.Errorf("clothing %s, want %s", got, want) + } + if got, want := fmt.Sprint(get("furni")), fmt.Sprint([]int64{local("sofasofa22"), 31162856, 108363}); got != want { + t.Errorf("furni %s, want %s", got, want) + } + if got, want := fmt.Sprint(get("scenes")), fmt.Sprint([]int64{31162856, 108363}); got != want { + t.Errorf("scenes %s, want %s", got, want) + } +} + +func TestShopSorting(t *testing.T) { + day := func(n int) time.Time { return time.Date(2026, 10, n, 0, 0, 0, 0, time.UTC) } + list := func() []hub.Product { + return []hub.Product{ + {ID: "a", Name: "Long Red Coat", CreatedAt: day(5)}, + {ID: "b", Name: "Coat", CreatedAt: day(1)}, + {ID: "c", Name: "Coats & Jackets Pack", CreatedAt: day(2)}, + {ID: "d", Name: "Raincoat", CreatedAt: day(4)}, + {ID: "e", Name: "Hat", Creator: "coat_maker", CreatedAt: day(3)}, + } + } + order := func(l []hub.Product) string { + s := "" + for _, p := range l { + s += p.ID + } + return s + } + for _, c := range []struct{ query, field, dir, want string }{ + {"coat", "conversion_score_b", "desc", "bcade"}, // same name, starts with, word, inside a word, creator + {"", "conversion_score_b", "desc", "adecb"}, // no search: newest + {"coat", "id", "desc", "adecb"}, // Newest Products + {"coat", "id", "asc", "bceda"}, + {"coat", "price", "asc", "adecb"}, // all free: newest + {"coat", "user_rating", "desc", "adecb"}, + } { + l := list() + sortShop(l, c.query, c.field, c.dir) + if got := order(l); got != c.want { + t.Errorf("%q %s %s: %s, want %s", c.query, c.field, c.dir, got, c.want) + } + } +} diff --git a/internal/products/shop.go b/internal/products/shop.go index 3aa937e..6295d2e 100644 --- a/internal/products/shop.go +++ b/internal/products/shop.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "path/filepath" + "sort" "strconv" "strings" "sync" @@ -37,6 +38,8 @@ func (p *Products) interceptShopSearch(x *proxy.Exchange) error { Page json.Number `json:"page"` Skip json.Number `json:"skip"` PerPage json.Number `json:"results_per_page"` + Sort string `json:"sort_field"` + Dir string `json:"sort_direction"` Limit json.Number `json:"solr_limit"` } if json.Unmarshal(x.Body, &req) != nil || req.Rating != ShopRating { @@ -67,6 +70,7 @@ func (p *Products) interceptShopSearch(x *proxy.Exchange) error { } p.mu.Unlock() list = inCategories(list, categoryIDs(req.Category)) + sortShop(list, query, req.Sort, req.Dir) p.fetchForShop(ctx, list) var rows []shopRow @@ -244,6 +248,41 @@ func shopAnswer(rows []shopRow, page, skip, perPage, limit int) []byte { return b } +// sortShop orders products for the shop's sort menu. T5 products have no ratings or prices, so those sorts are by +// newest; Best Match (conversion_score_b, score) with a search puts the closest names first (the same name, then +// names starting with it, then words starting with it, then the rest), newest first among equals; Newest +// (id) is newest first, or oldest first ascending. +func sortShop(list []hub.Product, query, field, direction string) { + q := strings.ToLower(strings.TrimSpace(query)) + rank := func(hp hub.Product) int { + if q == "" || (field != "conversion_score_b" && field != "score" && field != "") { + return 0 + } + name := strings.ToLower(hp.Name) + switch { + case name == q: + return 0 + case strings.HasPrefix(name, q): + return 1 + case strings.Contains(" "+name, " "+q): + return 2 + case strings.Contains(name, q): + return 3 + } + return 4 // matched by creator + } + oldest := field == "id" && direction == "asc" + sort.SliceStable(list, func(i, j int) bool { + if ri, rj := rank(list[i]), rank(list[j]); ri != rj { + return ri < rj + } + if oldest { + return list[i].CreatedAt.Before(list[j].CreatedAt) + } + return list[i].CreatedAt.After(list[j].CreatedAt) + }) +} + // categoryIDs reads the shop's category field: IDs separated by commas, as a string or number (empty: all). func categoryIDs(raw json.RawMessage) map[int64]bool { var s string