Show T5 products in the client's shop; keep float IDs and unknown paths off IMVU
CI / build (push) Successful in 34s
CI / build (push) Successful in 34s
The shop's T5 filter (maturity_rating "t5") is answered from the hub's products, fetched so they can be
shown and tried on; buying T5 products adds them to the hub library, and product.php details for
community products are answered here.
The client sends some product IDs as floats ("2000000008.0"): product.php passed the worn community
product to IMVU that way. Every ID is now read either way, and products.Guard refuses any request to
IMVU that still names a local ID given out. T5DE_PROXY_DUMP logs request and response bodies.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
6506c641f0
commit
2280ad6314
14 files changed
+785
-18
No files matched your search
@@ -44,7 +44,8 @@ itself:
|
|||||||
checksum): for a product deriving from an IMVU product, IMVU is asked about the parent and the proxy adds its own
|
checksum): for a product deriving from an IMVU product, IMVU is asked about the parent and the proxy adds its own
|
||||||
entry (the client needs the parent's derivation chain).
|
entry (the client needs the parent's derivation chain).
|
||||||
- `productdata/<local ID>/<revision>`: the pack built into a CFL.
|
- `productdata/<local ID>/<revision>`: the pack built into a CFL.
|
||||||
- `api/shop/product.php` (putting a product on asks whether it's a bundle).
|
- `api/shop/product.php`: putting a product on asks whether it's a bundle (not), and the shop asks for product
|
||||||
|
details (answered from the hub's listing, or the pack; IMVU is asked only about its own products).
|
||||||
|
|
||||||
IMVU never sees community products: they're taken out of outfit saves (`api/base_look.php`) and outfit chat
|
IMVU never sees community products: they're taken out of outfit saves (`api/base_look.php`) and outfit chat
|
||||||
commands (`*use`, `*putOn`, `*putOnOutfit`, `*remove`, `*takeOff` on `/chat/` queues; a command left empty is
|
commands (`*use`, `*putOn`, `*putOnOutfit`, `*remove`, `*takeOff` on `/chat/` queues; a command left empty is
|
||||||
@@ -53,6 +54,9 @@ products with the outfit they were saved with, and adds them back when the clien
|
|||||||
(`base_look.php`, and `look` in the login response); an outfit changed elsewhere comes back without them.
|
(`base_look.php`, and `look` in the login response); an outfit changed elsewhere comes back without them.
|
||||||
Requests it changes are re-signed (`X-imvu-auth`) with the session key from the login response, which never
|
Requests it changes are re-signed (`X-imvu-auth`) with the session key from the login response, which never
|
||||||
leaves the machine.
|
leaves the machine.
|
||||||
|
The client sends some product IDs as floats (`2000000008.0`), so every ID is read either way (`parseID`). As a
|
||||||
|
last check, `products.Guard` refuses any request to IMVU that still names a local ID given out, and logs it
|
||||||
|
(`products: refused a request to ...`): such a request is a path not handled yet.
|
||||||
|
|
||||||
## The hub
|
## The hub
|
||||||
|
|
||||||
@@ -69,6 +73,17 @@ and what they wear; the proxy gives the client those of their products it has pa
|
|||||||
from them, and adds them to the whole outfits they send. It also keeps community products out of the outfit the
|
from them, and adds them to the whole outfits they send. It also keeps community products out of the outfit the
|
||||||
client posts to IMVU's chat participant endpoint.
|
client posts to IMVU's chat participant endpoint.
|
||||||
|
|
||||||
|
The shop (the client's `t5de-shop` patch adds a T5 choice to its rating filter): searches with
|
||||||
|
`maturity_rating` `t5` (`api/shop/search_st.php`) are answered from the hub's products (`GET /api/v1/products`,
|
||||||
|
filtered by the shop's categories against each product's IMVU parent's category path), fetching listed products
|
||||||
|
that aren't here yet (up to 15 seconds) so they can be shown and tried on. Buying T5 products
|
||||||
|
(`api/service/shop/svc_catalog_purchase.php`) adds them to the person's hub library; IMVU products in the same
|
||||||
|
purchase go on to IMVU without them.
|
||||||
|
|
||||||
|
For finding out what IMVU's APIs send, `T5DE_PROXY_DUMP=<text>` logs the request and response bodies of requests
|
||||||
|
whose path contains it (test accounts only: bodies hold personal data). In Git Bash, keep the value from starting
|
||||||
|
with `/` (or set `MSYS_NO_PATHCONV=1`), or it's turned into a Windows path.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
go test ./...
|
go test ./...
|
||||||
|
|||||||
+11
-3
@@ -153,6 +153,10 @@ func run(logger *log.Logger, listen, ready string, parentPID int, trace bool, pa
|
|||||||
fetcher.OnLibrary = prods.RefreshInventory
|
fetcher.OnLibrary = prods.RefreshInventory
|
||||||
prods.Want = fetcher.Want
|
prods.Want = fetcher.Want
|
||||||
prods.HubDir, prods.Owned = hubProducts, fetcher.Owned
|
prods.HubDir, prods.Owned = hubProducts, fetcher.Owned
|
||||||
|
prods.Shop, prods.Fetch = client.Products, fetcher.Ensure
|
||||||
|
prods.AddToLibrary = func(ctx context.Context, id string) error {
|
||||||
|
return client.Call(ctx, "PUT", "/api/v1/library/"+id, map[string]any{}, nil)
|
||||||
|
}
|
||||||
prods.OnHide = func(id string) {
|
prods.OnHide = func(id string) {
|
||||||
if fetch.IsHubID(id) {
|
if fetch.IsHubID(id) {
|
||||||
go func() {
|
go func() {
|
||||||
@@ -164,11 +168,15 @@ func run(logger *log.Logger, listen, ready string, parentPID int, trace bool, pa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
manager.Start()
|
manager.Start()
|
||||||
|
chain := proxy.Interceptors{manager}
|
||||||
if prods != nil {
|
if prods != nil {
|
||||||
handler.Interceptor = proxy.Interceptors{manager, prods}
|
chain = append(chain, prods)
|
||||||
} else {
|
|
||||||
handler.Interceptor = manager
|
|
||||||
}
|
}
|
||||||
|
chain = append(chain, proxy.Dump{Match: os.Getenv("T5DE_PROXY_DUMP"), Log: logger})
|
||||||
|
if prods != nil {
|
||||||
|
chain = append(chain, products.Guard{Products: prods}) // last: nothing naming a community product reaches IMVU
|
||||||
|
}
|
||||||
|
handler.Interceptor = chain
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
ReadHeaderTimeout: 30 * time.Second,
|
ReadHeaderTimeout: 30 * time.Second,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -491,3 +492,28 @@ func (c *Client) AccessToken(ctx context.Context) (string, error) {
|
|||||||
err := c.Call(ctx, "POST", "/api/v1/access", nil, &out)
|
err := c.Call(ctx, "POST", "/api/v1/access", nil, &out)
|
||||||
return out.Token, err
|
return out.Token, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Product is a product as the hub lists it (GET /api/v1/products).
|
||||||
|
type Product struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Creator string `json:"creator"`
|
||||||
|
Rating string `json:"rating"`
|
||||||
|
Latest int `json:"latest"`
|
||||||
|
Parent int64 `json:"parent"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
Categories []Category `json:"categories"` // the parent's IMVU category path, top first
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category is an IMVU shop category.
|
||||||
|
type Category struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Products searches the hub's products (by name; all if query is empty), newest first.
|
||||||
|
func (c *Client) Products(ctx context.Context, query string) ([]Product, error) {
|
||||||
|
var out struct{ Products []Product }
|
||||||
|
err := c.Call(ctx, "GET", "/api/v1/products?limit=200&q="+url.QueryEscape(query), nil, &out)
|
||||||
|
return out.Products, err
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package products
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,15 +33,15 @@ func stripCommand(text string, isCommunity func(int64) bool) strippedCommand {
|
|||||||
r := strippedCommand{text: text}
|
r := strippedCommand{text: text}
|
||||||
kept := []string{fields[0]}
|
kept := []string{fields[0]}
|
||||||
for _, f := range fields[1:] {
|
for _, f := range fields[1:] {
|
||||||
id, err := strconv.ParseInt(f, 10, 64)
|
id, ok := parseID(f)
|
||||||
if err == nil && id == bodyProduct && fields[0] == "*use" {
|
if ok && id == bodyProduct && fields[0] == "*use" {
|
||||||
r.outfit = true
|
r.outfit = true
|
||||||
}
|
}
|
||||||
if err == nil && isCommunity(id) {
|
if ok && isCommunity(id) {
|
||||||
r.products = append(r.products, id)
|
r.products = append(r.products, id)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err == nil {
|
if ok {
|
||||||
r.kept = append(r.kept, id)
|
r.kept = append(r.kept, id)
|
||||||
}
|
}
|
||||||
kept = append(kept, f)
|
kept = append(kept, f)
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package products
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Guard is the last check before a request goes to IMVU: one that still names a community product (a local ID the
|
||||||
|
// proxy gave out, in its query or body, as an integer or a float) is refused, not sent. The interceptors take
|
||||||
|
// community products out of every request known to carry them; Guard stops the ones not known yet, and logs them
|
||||||
|
// so they can be handled.
|
||||||
|
type Guard struct{ Products *Products }
|
||||||
|
|
||||||
|
var localIDPattern = regexp.MustCompile(`(?:^|[^0-9.])(2[0-9]{9})(?:\.0+)?(?:[^0-9.]|$)`)
|
||||||
|
|
||||||
|
func (g Guard) Intercept(x *proxy.Exchange) error {
|
||||||
|
if x.Response != nil || !strings.HasSuffix(strings.ToLower(x.URL.Hostname()), ".imvu.com") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
query, _ := url.QueryUnescape(x.URL.RawQuery)
|
||||||
|
for _, text := range []string{query, x.URL.Path, string(x.Body)} {
|
||||||
|
for _, m := range localIDPattern.FindAllStringSubmatch(text, -1) {
|
||||||
|
id, ok := parseID(m[1])
|
||||||
|
if ok && IsCommunity(id) && g.Products.given(id) {
|
||||||
|
g.Products.logf("products: refused a request to %s%s: it names community product %d", x.URL.Host, x.URL.Path, id)
|
||||||
|
x.Response = localResponse(403, "application/json", []byte(`{"error":"t5de: community products stay off IMVU"}`))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// given reports whether a local ID was given to a product.
|
||||||
|
func (p *Products) given(local int64) bool {
|
||||||
|
_, ok := p.state.productID(local)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package products
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGuard(t *testing.T) {
|
||||||
|
h, _ := newHarness(t)
|
||||||
|
local := h.p.byProductID("blue-coat").local
|
||||||
|
g := Guard{Products: h.p}
|
||||||
|
check := func(rawURL, body string, refused bool) {
|
||||||
|
t.Helper()
|
||||||
|
u, _ := url.Parse(rawURL)
|
||||||
|
req, _ := http.NewRequest("POST", rawURL, nil)
|
||||||
|
x := &proxy.Exchange{URL: u, Request: req, Body: []byte(body)}
|
||||||
|
g.Intercept(x)
|
||||||
|
if (x.Response != nil) != refused {
|
||||||
|
t.Errorf("%s %s: refused %v", rawURL, body, x.Response != nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check("https://client-dynamic.imvu.com/api/new.php", fmt.Sprintf(`{"pids": [80, %d.0]}`, local), true)
|
||||||
|
check(fmt.Sprintf("https://api.imvu.com/product/product-%d", local), "", true)
|
||||||
|
check(fmt.Sprintf("https://client-dynamic.imvu.com/api/x.php?ids=1%%2C%d", local), "", true)
|
||||||
|
check("https://client-dynamic.imvu.com/api/new.php", `{"pids": [80, 2000000777]}`, false) // not given out
|
||||||
|
check("https://client-dynamic.imvu.com/api/new.php", fmt.Sprintf(`{"n": "1%d"}`, local), false)
|
||||||
|
check(fmt.Sprintf("https://hub.example/%d", local), "", false) // only IMVU's hosts
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package products
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseID reads a product ID as the client writes it: "2000000008", or as a float, "2000000008.0" (its JavaScript
|
||||||
|
// numbers go through Python as floats). Every place that takes community products out of what IMVU gets uses it, so a
|
||||||
|
// float can't slip through as "not a product ID".
|
||||||
|
func parseID(s string) (int64, bool) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if id, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
f, err := strconv.ParseFloat(s, 64)
|
||||||
|
if err != nil || f != math.Trunc(f) || math.Abs(f) > 1<<53 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return int64(f), true
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package products
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseID(t *testing.T) {
|
||||||
|
for s, want := range map[string]int64{"2000000008": 2000000008, "2000000008.0": 2000000008, " 80 ": 80, "2e9": 2000000000} {
|
||||||
|
if got, ok := parseID(s); !ok || got != want {
|
||||||
|
t.Errorf("parseID(%q) = %d, %v", s, got, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range []string{"", "1.5", "abc", "1e300"} {
|
||||||
|
if _, ok := parseID(s); ok {
|
||||||
|
t.Errorf("parseID(%q) ok", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,9 +40,15 @@ func (p *Products) Intercept(x *proxy.Exchange) error {
|
|||||||
return p.interceptXMLRPC(x)
|
return p.interceptXMLRPC(x)
|
||||||
case "/api/shop/product.php":
|
case "/api/shop/product.php":
|
||||||
// Putting a product on asks the shop whether it's a bundle; community products aren't.
|
// Putting a product on asks the shop whether it's a bundle; community products aren't.
|
||||||
if id, err := strconv.ParseInt(x.URL.Query().Get("pid"), 10, 64); err == nil && IsCommunity(id) {
|
if id, ok := parseID(x.URL.Query().Get("pid")); ok && IsCommunity(id) {
|
||||||
x.Response = localResponse(200, "application/json", []byte(`{"products":[]}`))
|
x.Response = localResponse(200, "application/json", []byte(`{"products":[]}`))
|
||||||
|
} else if len(x.Body) > 0 {
|
||||||
|
return p.interceptProductInfo(x)
|
||||||
}
|
}
|
||||||
|
case "/api/shop/search_st.php":
|
||||||
|
return p.interceptShopSearch(x)
|
||||||
|
case "/api/service/shop/svc_catalog_purchase.php":
|
||||||
|
return p.interceptPurchase(x)
|
||||||
case "/api/base_look.php":
|
case "/api/base_look.php":
|
||||||
return p.interceptBaseLook(x)
|
return p.interceptBaseLook(x)
|
||||||
case "/api/service/inventory/inventory_delete.php":
|
case "/api/service/inventory/inventory_delete.php":
|
||||||
@@ -166,11 +172,18 @@ func productList(v xmlrpc.Value) ([]int64, bool) {
|
|||||||
}
|
}
|
||||||
ids := make([]int64, 0, len(list))
|
ids := make([]int64, 0, len(list))
|
||||||
for _, v := range list {
|
for _, v := range list {
|
||||||
n, ok := v.(int64)
|
switch n := v.(type) {
|
||||||
if !ok {
|
case int64:
|
||||||
|
ids = append(ids, n)
|
||||||
|
case float64:
|
||||||
|
id, ok := parseID(strconv.FormatFloat(n, 'f', -1, 64))
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
default:
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
ids = append(ids, n)
|
|
||||||
}
|
}
|
||||||
return ids, true
|
return ids, true
|
||||||
}
|
}
|
||||||
@@ -568,8 +581,8 @@ func (p *Products) interceptHide(x *proxy.Exchange) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
pid, err := strconv.ParseInt(form.Get("pid"), 10, 64)
|
pid, ok := parseID(form.Get("pid"))
|
||||||
if err != nil || !IsCommunity(pid) {
|
if !ok || !IsCommunity(pid) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if id, ok := p.state.productID(pid); ok && p.OnHide != nil {
|
if id, ok := p.state.productID(pid); ok && p.OnHide != nil {
|
||||||
@@ -596,8 +609,8 @@ func (p *Products) interceptOutfits(x *proxy.Exchange) error {
|
|||||||
var community []string
|
var community []string
|
||||||
stripped := 0
|
stripped := 0
|
||||||
for _, f := range strings.Split(products, ",") {
|
for _, f := range strings.Split(products, ",") {
|
||||||
id, err := strconv.ParseInt(strings.TrimSpace(f), 10, 64)
|
id, ok := parseID(f)
|
||||||
if err == nil && IsCommunity(id) {
|
if ok && IsCommunity(id) {
|
||||||
stripped++ // always kept from IMVU, even if its product isn't known anymore
|
stripped++ // always kept from IMVU, even if its product isn't known anymore
|
||||||
if pid, ok := p.state.productID(id); ok {
|
if pid, ok := p.state.productID(id); ok {
|
||||||
community = append(community, pid)
|
community = append(community, pid)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ package products
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -58,10 +59,17 @@ type Products struct {
|
|||||||
// library, for hub products).
|
// library, for hub products).
|
||||||
OnHide func(id string)
|
OnHide func(id string)
|
||||||
|
|
||||||
|
// Shop, if set, searches the hub's products for the shop's T5 filter; Fetch gets one into the hub's products
|
||||||
|
// now; AddToLibrary adds one to the person's library (buying it in the shop).
|
||||||
|
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
|
||||||
|
|
||||||
roomMu sync.Mutex
|
roomMu sync.Mutex
|
||||||
room room
|
room room
|
||||||
|
|
||||||
user string // the signed-in IMVU account (from the login answer), under mu
|
user string // the signed-in IMVU account (from the login answer), under mu
|
||||||
|
shopSeen map[string]hub.Product // hub products as the shop last listed them, under mu
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ready is told a product arrived (the fetcher wrote it): it's served from now on, and given to the client for
|
// Ready is told a product arrived (the fetcher wrote it): it's served from now on, and given to the client for
|
||||||
|
|||||||
@@ -73,6 +73,17 @@ func (f *fakeIMVU) RoundTrip(r *http.Request) (*http.Response, error) {
|
|||||||
return reply(`{"status": "success"}`)
|
return reply(`{"status": "success"}`)
|
||||||
}
|
}
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
|
case "/api/shop/product.php":
|
||||||
|
checkSigned(false)
|
||||||
|
f.record("product info", body)
|
||||||
|
return reply(`{"products": [{"id": "12345", "name": "IMVU's"}]}`)
|
||||||
|
case "/api/shop/search_st.php":
|
||||||
|
f.record("search", body)
|
||||||
|
return reply(`{"keys": [], "result": {}}`)
|
||||||
|
case "/api/service/shop/svc_catalog_purchase.php":
|
||||||
|
checkSigned(false)
|
||||||
|
f.record("purchase", body)
|
||||||
|
return reply(`{"status": 1, "balance_credits": 10, "balance_promo": 0}`)
|
||||||
case "/api/outfits.php":
|
case "/api/outfits.php":
|
||||||
if len(body) > 0 {
|
if len(body) > 0 {
|
||||||
checkSigned(false)
|
checkSigned(false)
|
||||||
|
|||||||
@@ -0,0 +1,392 @@
|
|||||||
|
package products
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The client's shop shows T5 products under its T5 filter (the t5de-shop patch), which searches with
|
||||||
|
// maturity_rating "t5": the proxy answers those searches from the hub instead of asking IMVU. Buying a T5 product
|
||||||
|
// adds it to the person's library on the hub; it's free, and IMVU never hears of it.
|
||||||
|
|
||||||
|
// ShopRating is the shop's maturity_rating for T5 products.
|
||||||
|
const ShopRating = "t5"
|
||||||
|
|
||||||
|
// shopKeys are search_st.php's columns, as IMVU sends them.
|
||||||
|
var shopKeys = []string{"id", "name", "price", "discount_price", "visible", "ap", "applus", "image", "creator_id",
|
||||||
|
"creator_name", "pro", "owns_product", "in_wishlist", "category", "category_name", "parent_categories",
|
||||||
|
"sub_pids", "index", "new", "sponsored", "purchasable"}
|
||||||
|
|
||||||
|
// shopFetchTime is how long a search waits for products to arrive from catalogs (the rest show on a later search).
|
||||||
|
var shopFetchTime = 15 * time.Second
|
||||||
|
|
||||||
|
func (p *Products) interceptShopSearch(x *proxy.Exchange) error {
|
||||||
|
var req struct {
|
||||||
|
Rating string `json:"maturity_rating"`
|
||||||
|
Search *string `json:"search"`
|
||||||
|
Category json.RawMessage `json:"category"`
|
||||||
|
Page json.Number `json:"page"`
|
||||||
|
Skip json.Number `json:"skip"`
|
||||||
|
PerPage json.Number `json:"results_per_page"`
|
||||||
|
Limit json.Number `json:"solr_limit"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(x.Body, &req) != nil || req.Rating != ShopRating {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if p.Shop == nil {
|
||||||
|
x.Response = localResponse(200, "application/json", shopAnswer(nil, 1, 0, 12, 24))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), shopFetchTime+5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
query := ""
|
||||||
|
if req.Search != nil {
|
||||||
|
query = strings.TrimSpace(*req.Search)
|
||||||
|
}
|
||||||
|
list, err := p.Shop(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
p.logf("shop: searching the hub: %v", err)
|
||||||
|
x.Response = localResponse(502, "application/json", []byte(`{"error":"hub"}`))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
if p.shopSeen == nil {
|
||||||
|
p.shopSeen = map[string]hub.Product{}
|
||||||
|
}
|
||||||
|
for _, hp := range list {
|
||||||
|
p.shopSeen[hp.ID] = hp
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
list = inCategories(list, categoryIDs(req.Category))
|
||||||
|
p.fetchForShop(ctx, list)
|
||||||
|
|
||||||
|
var rows []shopRow
|
||||||
|
for _, hp := range list {
|
||||||
|
if s := p.byProductID(hp.ID); s != nil {
|
||||||
|
rows = append(rows, shopRow{Product: hp, local: s.local})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
page, skip, perPage, limit := number(req.Page, 1), number(req.Skip, 0), number(req.PerPage, 12), number(req.Limit, 24)
|
||||||
|
if perPage < 1 {
|
||||||
|
perPage = 12
|
||||||
|
}
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if skip < 0 {
|
||||||
|
skip = (page - 1) * perPage
|
||||||
|
}
|
||||||
|
for i := range rows {
|
||||||
|
rows[i].owned = p.owns(rows[i].ID)
|
||||||
|
}
|
||||||
|
x.Response = localResponse(200, "application/json", shopAnswer(rows, page, skip, perPage, limit))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type shopRow struct {
|
||||||
|
hub.Product
|
||||||
|
local int64
|
||||||
|
owned bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r shopRow) cells(index int) []any {
|
||||||
|
ap := 0
|
||||||
|
if r.Rating == "adult" {
|
||||||
|
ap = 1
|
||||||
|
}
|
||||||
|
category, categoryName := int64(0), ""
|
||||||
|
parents := []hub.Category{}
|
||||||
|
if n := len(r.Categories); n > 0 {
|
||||||
|
category, categoryName = r.Categories[n-1].ID, categoryText(r.Categories[n-1].Name)
|
||||||
|
for _, c := range r.Categories[:n-1] {
|
||||||
|
parents = append(parents, hub.Category{ID: c.ID, Name: categoryText(c.Name)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []any{strconv.FormatInt(r.local, 10), plain(r.Name), 0, 0, 1, ap, 0, r.Image, "0", plain(r.Creator), 0,
|
||||||
|
r.owned, 0, category, categoryName, parents, "", index, 0, 0, 1}
|
||||||
|
}
|
||||||
|
|
||||||
|
// categoryText is an IMVU category name (from the hub), kept to what plain allows plus "&" ("Coats & Jackets"):
|
||||||
|
// a lone "&" can't start markup.
|
||||||
|
func categoryText(s string) string {
|
||||||
|
parts := strings.Split(s, "&")
|
||||||
|
for i, part := range parts {
|
||||||
|
parts[i] = plain(part)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "&")
|
||||||
|
}
|
||||||
|
|
||||||
|
// owns reports whether the person has a product: local packs always, hub products in their library.
|
||||||
|
func (p *Products) owns(id string) bool {
|
||||||
|
if p.inHubDir(id) {
|
||||||
|
return p.Owned != nil && p.Owned(id)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// object is the row as one of product.php's products (keys to values).
|
||||||
|
func (r shopRow) object() map[string]any {
|
||||||
|
cells := r.cells(0)
|
||||||
|
out := map[string]any{}
|
||||||
|
for i, k := range shopKeys {
|
||||||
|
out[k] = cells[i]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// interceptProductInfo answers product.php's product details for community products, and asks IMVU only for its
|
||||||
|
// own.
|
||||||
|
func (p *Products) interceptProductInfo(x *proxy.Exchange) error {
|
||||||
|
var body map[string]json.RawMessage
|
||||||
|
if json.Unmarshal(x.Body, &body) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var pids []json.RawMessage
|
||||||
|
if json.Unmarshal(body["pids"], &pids) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var keep []json.RawMessage
|
||||||
|
var ours []any
|
||||||
|
community := 0
|
||||||
|
for _, raw := range pids {
|
||||||
|
id, ok := rawID(raw)
|
||||||
|
if !ok || !IsCommunity(id) {
|
||||||
|
keep = append(keep, raw)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
community++
|
||||||
|
if r, ok := p.shopRowFor(id); ok {
|
||||||
|
ours = append(ours, r.object())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if community == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(keep) == 0 {
|
||||||
|
b, _ := json.Marshal(map[string]any{"products": ours})
|
||||||
|
x.Response = localResponse(200, "application/json", b)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
body["pids"], _ = json.Marshal(keep)
|
||||||
|
nb, _ := json.Marshal(body)
|
||||||
|
if !p.keys.resign(x.Request.Header, x.Body, x.URL.RawQuery, nb, x.URL.RawQuery) {
|
||||||
|
return errNoKey
|
||||||
|
}
|
||||||
|
x.Body = nb
|
||||||
|
x.ModifyResponse = func(resp *http.Response, b []byte) ([]byte, error) {
|
||||||
|
var r map[string]json.RawMessage
|
||||||
|
var list []any
|
||||||
|
if json.Unmarshal(b, &r) != nil || json.Unmarshal(r["products"], &list) != nil {
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
r["products"], _ = json.Marshal(append(list, ours...))
|
||||||
|
return json.Marshal(r)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// shopRowFor is a community product's shop entry: as the hub listed it, else from its pack.
|
||||||
|
func (p *Products) shopRowFor(local int64) (shopRow, bool) {
|
||||||
|
s := p.lookup(local)
|
||||||
|
if s == nil {
|
||||||
|
return shopRow{}, false
|
||||||
|
}
|
||||||
|
m := s.pack.Manifest
|
||||||
|
p.mu.Lock()
|
||||||
|
hp, ok := p.shopSeen[m.ID]
|
||||||
|
p.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
hp = hub.Product{ID: m.ID, Name: m.Name, Creator: m.Creator, Rating: m.Rating, Latest: m.Revision}
|
||||||
|
}
|
||||||
|
return shopRow{Product: hp, local: s.local, owned: p.owns(m.ID)}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawID reads a product ID sent as a number or a string.
|
||||||
|
func rawID(raw json.RawMessage) (int64, bool) {
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(raw, &s) != nil {
|
||||||
|
var n json.Number
|
||||||
|
if json.Unmarshal(raw, &n) != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
s = n.String()
|
||||||
|
}
|
||||||
|
return parseID(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// shopAnswer is a search_st.php answer: the pages from page on, holding up to limit products from skip.
|
||||||
|
func shopAnswer(rows []shopRow, page, skip, perPage, limit int) []byte {
|
||||||
|
result := map[string][]any{}
|
||||||
|
end := min(skip+limit, len(rows))
|
||||||
|
for i := skip; i < end; i++ {
|
||||||
|
key := strconv.Itoa(page + (i-skip)/perPage)
|
||||||
|
result[key] = append(result[key], rows[i].cells(i))
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(map[string]any{
|
||||||
|
"keys": shopKeys,
|
||||||
|
"startIndex": skip,
|
||||||
|
"endIndex": end - 1,
|
||||||
|
"result": result,
|
||||||
|
"totalProductCount": len(rows),
|
||||||
|
"thisPageProductCount": max(end-skip, 0),
|
||||||
|
"pageCount": (len(rows) + perPage - 1) / perPage,
|
||||||
|
"thisPage": page,
|
||||||
|
})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
if json.Unmarshal(raw, &s) != nil {
|
||||||
|
var n json.Number
|
||||||
|
if json.Unmarshal(raw, &n) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s = n.String()
|
||||||
|
}
|
||||||
|
ids := map[int64]bool{}
|
||||||
|
for _, f := range strings.Split(s, ",") {
|
||||||
|
if id, ok := parseID(f); ok {
|
||||||
|
ids[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// inCategories keeps the products under any of the categories (all of them if there are none).
|
||||||
|
func inCategories(list []hub.Product, ids map[int64]bool) []hub.Product {
|
||||||
|
if ids == nil {
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
var out []hub.Product
|
||||||
|
for _, hp := range list {
|
||||||
|
for _, c := range hp.Categories {
|
||||||
|
if ids[c.ID] {
|
||||||
|
out = append(out, hp)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchForShop gets the listed products that aren't here (or are an older revision), a few at a time, so the shop
|
||||||
|
// can show them and they can be tried on.
|
||||||
|
func (p *Products) fetchForShop(ctx context.Context, list []hub.Product) {
|
||||||
|
if p.Fetch == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, shopFetchTime)
|
||||||
|
defer cancel()
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
slots := make(chan struct{}, 6)
|
||||||
|
for _, hp := range list {
|
||||||
|
if s := p.byProductID(hp.ID); s != nil && s.pack.Manifest.Revision >= hp.Latest {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id string) {
|
||||||
|
defer wg.Done()
|
||||||
|
select {
|
||||||
|
case slots <- struct{}{}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { <-slots }()
|
||||||
|
if err := p.Fetch(ctx, id); err != nil {
|
||||||
|
p.logf("shop: %s: %v", id, err)
|
||||||
|
}
|
||||||
|
}(hp.ID)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
p.refresh(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func number(n json.Number, fallback int) int {
|
||||||
|
if v, err := n.Int64(); err == nil {
|
||||||
|
return int(v)
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// interceptPurchase buys T5 products by adding them to the person's library. Products from IMVU in the same
|
||||||
|
// purchase go on to IMVU without the T5 ones.
|
||||||
|
func (p *Products) interceptPurchase(x *proxy.Exchange) error {
|
||||||
|
var body map[string]json.RawMessage
|
||||||
|
if json.Unmarshal(x.Body, &body) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var pids []json.RawMessage
|
||||||
|
if json.Unmarshal(body["products"], &pids) != nil {
|
||||||
|
var one json.RawMessage
|
||||||
|
if json.Unmarshal(body["products"], &one) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pids = []json.RawMessage{one}
|
||||||
|
}
|
||||||
|
var keep []json.RawMessage
|
||||||
|
var t5 []string
|
||||||
|
community := 0
|
||||||
|
for _, raw := range pids {
|
||||||
|
id, ok := rawID(raw)
|
||||||
|
if !ok || !IsCommunity(id) {
|
||||||
|
keep = append(keep, raw)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
community++
|
||||||
|
if pid, ok := p.state.productID(id); ok && p.HubDir != "" && p.inHubDir(pid) {
|
||||||
|
t5 = append(t5, pid)
|
||||||
|
} // local packs are always in the inventory: nothing to buy
|
||||||
|
}
|
||||||
|
if community == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// IMVU's part first (re-signed), so nothing is added to the library for a purchase that can't go on.
|
||||||
|
if len(keep) > 0 {
|
||||||
|
body["products"], _ = json.Marshal(keep)
|
||||||
|
nb, _ := json.Marshal(body)
|
||||||
|
if !p.keys.resign(x.Request.Header, x.Body, x.URL.RawQuery, nb, x.URL.RawQuery) {
|
||||||
|
return errNoKey
|
||||||
|
}
|
||||||
|
x.Body = nb
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
for _, pid := range t5 {
|
||||||
|
if p.AddToLibrary == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := p.AddToLibrary(ctx, pid); err != nil {
|
||||||
|
p.logf("shop: adding %s to the library: %v", pid, err)
|
||||||
|
x.Response = localResponse(200, "application/json", []byte(`{"status":3}`))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(t5) > 0 {
|
||||||
|
p.logf("shop: added %d T5 products to the library", len(t5))
|
||||||
|
}
|
||||||
|
if len(keep) == 0 {
|
||||||
|
// No credits change hands: the client's patch leaves the balance alone when the answer has none.
|
||||||
|
x.Response = localResponse(200, "application/json", []byte(`{"status":1}`))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// inHubDir reports whether a product is served from the hub's products (not a local pack).
|
||||||
|
func (p *Products) inHubDir(id string) bool {
|
||||||
|
s := p.byProductID(id)
|
||||||
|
return s != nil && filepath.Dir(s.dir) == filepath.Clean(p.HubDir)
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package products
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShop(t *testing.T) {
|
||||||
|
h, _ := newHarness(t)
|
||||||
|
hubDir := t.TempDir()
|
||||||
|
h.p.dirs = append(h.p.dirs, hubDir)
|
||||||
|
h.p.HubDir = hubDir
|
||||||
|
library := map[string]bool{"coatcoat22": true}
|
||||||
|
h.p.Owned = func(id string) bool { return library[id] }
|
||||||
|
coat := []hub.Category{{ID: 106, Name: "Clothing"}, {ID: 40, Name: "Clothing for Female"}, {ID: 716, Name: "Coats & <Jackets>"}}
|
||||||
|
hair := []hub.Category{{ID: 3114, Name: "Body"}, {ID: 3115, Name: "Body for Female"}, {ID: 75, Name: "Hair"}}
|
||||||
|
listed := []hub.Product{
|
||||||
|
{ID: "coatcoat22", Name: "Green Coat", Creator: "erin", Rating: "general", Latest: 1, Image: "https://userimages-akm.imvu.com/a.gif", Categories: coat},
|
||||||
|
{ID: "hairhair22", Name: "Red Hair", Creator: "erin", Rating: "adult", Latest: 2, Categories: hair},
|
||||||
|
{ID: "slowslow22", Name: "Never Arrives", Creator: "erin", Rating: "general", Latest: 1, Categories: coat},
|
||||||
|
}
|
||||||
|
var queries []string
|
||||||
|
h.p.Shop = func(ctx context.Context, q string) ([]hub.Product, error) {
|
||||||
|
queries = append(queries, q)
|
||||||
|
return listed, nil
|
||||||
|
}
|
||||||
|
fetched := map[string]int{}
|
||||||
|
var mu sync.Mutex
|
||||||
|
h.p.Fetch = func(ctx context.Context, id string) error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
fetched[id]++
|
||||||
|
if id == "slowslow22" {
|
||||||
|
return fmt.Errorf("no catalog hosts it")
|
||||||
|
}
|
||||||
|
for _, hp := range listed {
|
||||||
|
if hp.ID == id {
|
||||||
|
writePack(t, hubDir, id, hp.Latest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var added []string
|
||||||
|
h.p.AddToLibrary = func(ctx context.Context, id string) error {
|
||||||
|
added = append(added, id)
|
||||||
|
library[id] = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
search := func(rating, category string, page int) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
body, _ := json.Marshal(map[string]any{"category": category, "search": "coat", "skip": (page - 1) * 1, "maturity_rating": rating,
|
||||||
|
"results_per_page": 1, "solr_limit": 2, "page": page})
|
||||||
|
status, b := h.do("POST", "http://client-dynamic.imvu.com/api/shop/search_st.php", body, true, false)
|
||||||
|
var out map[string]any
|
||||||
|
if status != 200 || json.Unmarshal(b, &out) != nil {
|
||||||
|
t.Fatalf("search: %d %s", status, b)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other ratings go to IMVU.
|
||||||
|
search("all", "", 1)
|
||||||
|
if len(h.imvu.asked["search"]) != 1 || len(queries) != 0 {
|
||||||
|
t.Fatal("an IMVU search wasn't left to IMVU")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The T5 filter: the hub's products, fetched so they can be shown and tried on, in IMVU's columns.
|
||||||
|
out := search(ShopRating, "", 1)
|
||||||
|
if queries[0] != "coat" || fetched["coatcoat22"] != 1 || fetched["hairhair22"] != 1 {
|
||||||
|
t.Fatalf("queries %v, fetched %v", queries, fetched)
|
||||||
|
}
|
||||||
|
if out["totalProductCount"].(float64) != 2 || out["pageCount"].(float64) != 2 || out["endIndex"].(float64) != 1 {
|
||||||
|
t.Fatalf("answer %v", out)
|
||||||
|
}
|
||||||
|
result := out["result"].(map[string]any)
|
||||||
|
first, second := result["1"].([]any)[0].([]any), result["2"].([]any)[0].([]any)
|
||||||
|
row := map[string]any{}
|
||||||
|
for i, k := range out["keys"].([]any) {
|
||||||
|
row[k.(string)] = first[i]
|
||||||
|
}
|
||||||
|
coatLocal := h.p.byProductID("coatcoat22").local
|
||||||
|
if row["id"] != fmt.Sprint(coatLocal) || row["name"] != "Green Coat" || row["owns_product"] != true || row["ap"].(float64) != 0 ||
|
||||||
|
row["category"].(float64) != 716 || row["category_name"] != "Coats & Jackets" || row["image"] != "https://userimages-akm.imvu.com/a.gif" {
|
||||||
|
t.Fatalf("row %v", row)
|
||||||
|
}
|
||||||
|
if second[5].(float64) != 1 || second[11] != false {
|
||||||
|
t.Fatalf("the adult product, not owned: %v", second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Products here at the listed revision aren't fetched again; categories filter.
|
||||||
|
out = search(ShopRating, "3115,75", 1)
|
||||||
|
if fetched["coatcoat22"] != 1 || fetched["hairhair22"] != 1 || out["totalProductCount"].(float64) != 1 {
|
||||||
|
t.Fatalf("fetched %v, answer %v", fetched, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before signing in, a mixed purchase can't be re-signed: it fails without touching the library.
|
||||||
|
hairLocal := h.p.byProductID("hairhair22").local
|
||||||
|
purchase := func(pids ...any) string {
|
||||||
|
body, _ := json.Marshal(map[string]any{"products": pids, "context": "sitc", "source": "client"})
|
||||||
|
_, b := h.do("POST", "http://client-dynamic.imvu.com/api/service/shop/svc_catalog_purchase.php", body, true, false)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
if purchase(hairLocal, 12345); len(added) != 0 {
|
||||||
|
t.Fatal("added to the library for a failed purchase")
|
||||||
|
}
|
||||||
|
h.do("POST", "https://secure.imvu.com/api/xmlrpc/client.php", call("test.avatarInfoForLogin2"), false, false)
|
||||||
|
|
||||||
|
// Buying a T5 product adds it to the library; IMVU hears only of its own products.
|
||||||
|
// Product details: community ones answered here, IMVU asked only for its own.
|
||||||
|
info := func(pids ...any) []map[string]any {
|
||||||
|
body, _ := json.Marshal(map[string]any{"pids": pids})
|
||||||
|
_, b := h.do("POST", "http://client-dynamic.imvu.com/api/shop/product.php", body, true, false)
|
||||||
|
var out struct{ Products []map[string]any }
|
||||||
|
if json.Unmarshal(b, &out) != nil {
|
||||||
|
t.Fatalf("product info: %s", b)
|
||||||
|
}
|
||||||
|
return out.Products
|
||||||
|
}
|
||||||
|
if got := info(coatLocal); len(got) != 1 || got[0]["name"] != "Green Coat" || got[0]["owns_product"] != true || len(h.imvu.asked["product info"]) != 0 {
|
||||||
|
t.Fatalf("product info %v", got)
|
||||||
|
}
|
||||||
|
// The client sends some IDs as floats ("2000000008.0"): those are community products too.
|
||||||
|
body, _ := json.Marshal(map[string]any{"pids": []json.RawMessage{json.RawMessage(fmt.Sprintf("%d.0", coatLocal)), json.RawMessage("12345")}})
|
||||||
|
h.do("POST", "http://client-dynamic.imvu.com/api/shop/product.php", body, true, false)
|
||||||
|
if asked := h.imvu.asked["product info"]; len(asked) != 1 || strings.Contains(asked[0], fmt.Sprint(coatLocal)) {
|
||||||
|
t.Fatalf("a float community ID went to IMVU: %v", asked)
|
||||||
|
}
|
||||||
|
if got := info(fmt.Sprint(hairLocal), 12345); len(got) != 2 || got[0]["name"] != "IMVU's" || got[1]["ap"].(float64) != 1 ||
|
||||||
|
strings.Contains(h.imvu.asked["product info"][1], fmt.Sprint(hairLocal)) {
|
||||||
|
t.Fatalf("mixed product info %v, IMVU got %v", got, h.imvu.asked["product info"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := purchase(fmt.Sprint(hairLocal)); got != `{"status":1}` || len(added) != 1 || added[0] != "hairhair22" || len(h.imvu.asked["purchase"]) != 0 {
|
||||||
|
t.Fatalf("T5 purchase: %s, added %v", got, added)
|
||||||
|
}
|
||||||
|
if got := purchase(hairLocal, 12345); !strings.Contains(got, "balance_credits") || len(h.imvu.asked["purchase"]) != 1 ||
|
||||||
|
strings.Contains(h.imvu.asked["purchase"][0], fmt.Sprint(hairLocal)) || !strings.Contains(h.imvu.asked["purchase"][0], "12345") {
|
||||||
|
t.Fatalf("mixed purchase: %s, IMVU got %v", got, h.imvu.asked["purchase"])
|
||||||
|
}
|
||||||
|
purchase(12345)
|
||||||
|
if len(h.imvu.asked["purchase"]) != 2 {
|
||||||
|
t.Fatal("an IMVU purchase wasn't left to IMVU")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dump logs the request and response bodies of requests whose path contains Match (T5DE_PROXY_DUMP, for finding
|
||||||
|
// out what IMVU's APIs send). It may log personal data: only for test accounts.
|
||||||
|
type Dump struct {
|
||||||
|
Match string
|
||||||
|
Log *log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Dump) Intercept(x *Exchange) error {
|
||||||
|
if d.Match == "" || !strings.Contains(x.URL.Path, d.Match) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
d.Log.Printf("dump: %s %s\n%s", x.Request.Method, x.URL, x.Body)
|
||||||
|
next := x.ModifyResponse
|
||||||
|
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
|
||||||
|
if next != nil {
|
||||||
|
var err error
|
||||||
|
if body, err = next(resp, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.Log.Printf("dump: %s answered %d\n%s", x.URL.Path, resp.StatusCode, body)
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in new issue
Block a user