Serve community products from local packs
The proxy serves products from local packs (t5de-api's format) under local IDs from 2,000,000,000: product info and permission answers (with the parent's derivation chain from IMVU), the product's CFL, and the shop's bundle lookup. Community products are taken out of outfit saves and outfit chat commands before IMVU sees them, remembered per account with the outfit they belong to, and added back when the client loads that outfit. Changed requests are re-signed with the session key from the login response. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b2eed27b99
commit
887e8a2364
7 files changed
+1177
-21
No files matched your search
@@ -0,0 +1,308 @@
|
||||
package products
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.t5de.dev/t5de/t5de-api/cfl"
|
||||
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||
"git.t5de.dev/t5de/t5de-proxy/internal/xmlrpc"
|
||||
)
|
||||
|
||||
const (
|
||||
user = "7"
|
||||
key = "0123abcd"
|
||||
parent = 500 // the IMVU product the test pack derives from
|
||||
token = "tok"
|
||||
)
|
||||
|
||||
// writePack makes a pack deriving from IMVU product 500.
|
||||
func writePack(t *testing.T, root, id string, revision int) string {
|
||||
dir := filepath.Join(root, id)
|
||||
os.MkdirAll(filepath.Join(dir, "files"), 0o755)
|
||||
manifest := fmt.Sprintf(`{"format": 1, "id": %q, "revision": %d, "name": "Blue <Coat>", "creator": "Tester", "rating": "general"}`, id, revision)
|
||||
os.WriteFile(filepath.Join(dir, "t5de-product.json"), []byte(manifest), 0o644)
|
||||
index := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8" ?><Template><ProductRating>GA</ProductRating><__DATAIMPORT>product://%d/index.xml</__DATAIMPORT><ProductName>Old Name</ProductName></Template>`, parent)
|
||||
os.WriteFile(filepath.Join(dir, "files", "index.xml"), []byte(index), 0o644)
|
||||
os.WriteFile(filepath.Join(dir, "files", "tex.png"), []byte("not really a png"), 0o644)
|
||||
return dir
|
||||
}
|
||||
|
||||
// fakeIMVU answers the calls the client makes, checking signatures, and records what it was asked.
|
||||
type fakeIMVU struct {
|
||||
t *testing.T
|
||||
mu sync.Mutex
|
||||
asked map[string][]string // method or path -> bodies
|
||||
look []int64
|
||||
}
|
||||
|
||||
func (f *fakeIMVU) record(name string, body []byte) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.asked[name] = append(f.asked[name], string(body))
|
||||
}
|
||||
|
||||
func (f *fakeIMVU) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
var body []byte
|
||||
if r.Body != nil {
|
||||
body, _ = io.ReadAll(r.Body)
|
||||
}
|
||||
reply := func(s string) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(s)), Request: r}, nil
|
||||
}
|
||||
checkSigned := func(withQuery bool) {
|
||||
if r.Header.Get("X-imvu-auth") != sign(user, key, body, r.URL.RawQuery, withQuery) {
|
||||
f.t.Errorf("%s: bad signature", r.URL)
|
||||
}
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/api/base_look.php":
|
||||
if len(body) > 0 {
|
||||
checkSigned(true)
|
||||
f.record("save look", body)
|
||||
var save struct{ Pids []int64 }
|
||||
json.Unmarshal(body, &save)
|
||||
f.look = save.Pids
|
||||
return reply(`{"success": true}`)
|
||||
}
|
||||
b, _ := json.Marshal(map[string]any{"result": f.look})
|
||||
return reply(string(b))
|
||||
case "/api/xmlrpc/client.php":
|
||||
call, err := xmlrpc.ParseCall(body)
|
||||
if err != nil {
|
||||
f.t.Fatalf("bad call: %v", err)
|
||||
}
|
||||
f.record(call.Method, body)
|
||||
switch call.Method {
|
||||
case "test.avatarInfoForLogin2":
|
||||
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.inventoryProductBatch":
|
||||
checkSigned(false)
|
||||
var out []xmlrpc.Value
|
||||
for _, v := range call.Params[0].([]xmlrpc.Value) {
|
||||
out = append(out, xmlrpc.Struct{{Name: "products_id", Value: v}, {Name: "products_name", Value: "IMVU's"},
|
||||
{Name: "cPath", Value: "106_40"}, {Name: "manufacturers_name", Value: "IMVU Creator"}})
|
||||
}
|
||||
return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{out}}).Marshal()))
|
||||
case "test.authorizeProducts2":
|
||||
checkSigned(false)
|
||||
cols := []xmlrpc.Value{}
|
||||
for _, c := range authColumns {
|
||||
cols = append(cols, c)
|
||||
}
|
||||
rows := []xmlrpc.Value{cols}
|
||||
auths, _ := call.Params[0].(xmlrpc.Struct).Get("auths")
|
||||
for _, a := range auths.([]xmlrpc.Value)[1:] {
|
||||
pid := a.([]xmlrpc.Value)[1].(int64)
|
||||
rows = append(rows, []xmlrpc.Value{int64(7), int64(400), pid, "sum", fmt.Sprintf("http://x/productdata/%d/1", pid), "", false, false, int64(1), true, int64(40), true, ""},
|
||||
[]xmlrpc.Value{int64(7), int64(0), int64(400), "sum2", "http://x/productdata/400/1", "", false, false, int64(1), true, int64(40), true, ""})
|
||||
}
|
||||
return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{rows}}).Marshal()))
|
||||
}
|
||||
}
|
||||
f.t.Errorf("unexpected request to IMVU: %s", r.URL)
|
||||
return reply("")
|
||||
}
|
||||
|
||||
type harness struct {
|
||||
t *testing.T
|
||||
p *Products
|
||||
imvu *fakeIMVU
|
||||
proxy *httptest.Server
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T) (*harness, string) {
|
||||
packs := t.TempDir()
|
||||
writePack(t, packs, "blue-coat", 1)
|
||||
p, err := New([]string{packs}, t.TempDir(), t.Logf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeIMVU{t: t, asked: map[string][]string{}, look: []int64{80, 1}}
|
||||
srv := httptest.NewServer(&proxy.Server{Token: token, Transport: f, Interceptor: p, IMQFilter: p})
|
||||
t.Cleanup(srv.Close)
|
||||
return &harness{t: t, p: p, imvu: f, proxy: srv}, packs
|
||||
}
|
||||
|
||||
// do sends a request through the proxy as the client does: signed (if signed), in absolute form.
|
||||
func (h *harness) do(method, url string, body []byte, signed, withQuery bool) (int, []byte) {
|
||||
h.t.Helper()
|
||||
req, _ := http.NewRequest(method, url, bytes.NewReader(body))
|
||||
req.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("t5de:"+token)))
|
||||
if signed {
|
||||
req.Header.Set("X-imvu-userid", user)
|
||||
req.Header.Set("X-imvu-auth", sign(user, key, body, req.URL.RawQuery, withQuery))
|
||||
}
|
||||
conn, err := net.Dial("tcp", h.proxy.Listener.Addr().String())
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
req.WriteProxy(conn)
|
||||
resp, err := http.ReadResponse(bufio.NewReader(conn), req)
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, b
|
||||
}
|
||||
|
||||
func call(method string, params ...xmlrpc.Value) []byte {
|
||||
return (&xmlrpc.Call{Method: method, Params: params}).Marshal()
|
||||
}
|
||||
|
||||
func TestCommunityProductEndToEnd(t *testing.T) {
|
||||
h, _ := newHarness(t)
|
||||
local := h.p.byProductID("blue-coat").local
|
||||
if local != FirstLocalID {
|
||||
t.Fatalf("local ID %d", local)
|
||||
}
|
||||
api := "http://client-dynamic.imvu.com/api/xmlrpc/client.php"
|
||||
|
||||
// Logging in teaches the proxy the session key.
|
||||
h.do("POST", "https://secure.imvu.com/api/xmlrpc/client.php", call("test.avatarInfoForLogin2"), false, false)
|
||||
|
||||
// Product info: the community product's from its parent's, with its own name; IMVU is asked for the parent.
|
||||
_, body := h.do("POST", api, call("test.inventoryProductBatch", []xmlrpc.Value{int64(1), local}), true, false)
|
||||
r, err := xmlrpc.ParseResponse(body)
|
||||
if err != nil {
|
||||
t.Fatal(err, string(body))
|
||||
}
|
||||
list := r.Params[0].([]xmlrpc.Value)
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("%d entries: %s", len(list), body)
|
||||
}
|
||||
ours := list[1].(xmlrpc.Struct)
|
||||
if id, _ := ours.Get("products_id"); id != local {
|
||||
t.Fatalf("entry %v", ours)
|
||||
}
|
||||
if name, _ := ours.Get("products_name"); name != "Blue <Coat>" {
|
||||
t.Fatalf("name %v", name)
|
||||
}
|
||||
if path, _ := ours.Get("cPath"); path != "106_40" {
|
||||
t.Fatalf("cPath %v", path)
|
||||
}
|
||||
if asked := h.imvu.asked["test.inventoryProductBatch"]; len(asked) != 1 || strings.Contains(asked[0], fmt.Sprint(local)) || !strings.Contains(asked[0], "<int>500</int>") {
|
||||
t.Fatalf("IMVU was asked %v", asked)
|
||||
}
|
||||
|
||||
// Permission: our row (with the CFL's address and checksum) and IMVU's rows for the parent's chain.
|
||||
auth := xmlrpc.Struct{{Name: "auths", Value: []xmlrpc.Value{[]xmlrpc.Value{"userId", "productId"}, []xmlrpc.Value{int64(7), local}}},
|
||||
{Name: "use_extracted_assets_if_available", Value: true}, {Name: "max_supported_format", Value: int64(9)}}
|
||||
_, body = h.do("POST", api, call("test.authorizeProducts2", auth), true, false)
|
||||
r, err = xmlrpc.ParseResponse(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := r.Params[0].([]xmlrpc.Value)
|
||||
var cflAddress string
|
||||
for _, row := range rows[1:] {
|
||||
values := row.([]xmlrpc.Value)
|
||||
if values[2] == local {
|
||||
cflAddress = values[4].(string)
|
||||
if values[1] != int64(parent) || values[3] != h.p.byProductID("blue-coat").checksum || values[6] != true {
|
||||
t.Fatalf("our row %v", values)
|
||||
}
|
||||
}
|
||||
}
|
||||
if cflAddress == "" || len(rows) != 4 {
|
||||
t.Fatalf("rows %v", rows)
|
||||
}
|
||||
|
||||
// The CFL: the pack's files, with the pack's name in index.xml. IMVU is never asked.
|
||||
status, data := h.do("GET", cflAddress, nil, false, false)
|
||||
entries, err := cfl.Read(data)
|
||||
if status != 200 || err != nil || len(entries) != 2 {
|
||||
t.Fatalf("%d %v %v", status, err, entries)
|
||||
}
|
||||
if !bytes.Contains(entries[0].Data, []byte("<ProductName>Blue <Coat></ProductName>")) {
|
||||
t.Fatalf("index.xml %s", entries[0].Data)
|
||||
}
|
||||
if status, _ := h.do("GET", strings.Replace(cflAddress, "/1", "/2", 1), nil, false, false); status != 404 {
|
||||
t.Fatalf("another revision: %d", status)
|
||||
}
|
||||
|
||||
// Saving an outfit with it: IMVU gets the outfit without it; loading the outfit adds it back.
|
||||
h.do("POST", "http://client-dynamic.imvu.com/api/base_look.php", []byte(fmt.Sprintf(`{"pids": [80, 1, %d]}`, local)), true, true)
|
||||
if saved := h.imvu.asked["save look"]; len(saved) != 1 || saved[0] != `{"pids":[80,1]}` {
|
||||
t.Fatalf("IMVU saved %v", saved)
|
||||
}
|
||||
_, body = h.do("GET", "http://client-dynamic.imvu.com/api/base_look.php", nil, true, true)
|
||||
if string(body) != fmt.Sprintf(`{"result":[80,1,%d]}`, local) {
|
||||
t.Fatalf("look %s", body)
|
||||
}
|
||||
_, body = h.do("POST", "https://secure.imvu.com/api/xmlrpc/client.php", call("test.avatarInfoForLogin2"), false, false)
|
||||
if !bytes.Contains(body, []byte(fmt.Sprintf("<int>%d</int>", local))) {
|
||||
t.Fatalf("login look %s", body)
|
||||
}
|
||||
// If the outfit IMVU has changes (saved elsewhere), it comes back without community products.
|
||||
h.imvu.look = []int64{80, 2}
|
||||
_, body = h.do("GET", "http://client-dynamic.imvu.com/api/base_look.php", nil, true, true)
|
||||
if string(body) != `{"result":[80,2]}` {
|
||||
t.Fatalf("changed look %s", body)
|
||||
}
|
||||
|
||||
// The shop's bundle lookup is answered without IMVU.
|
||||
if _, body := h.do("GET", fmt.Sprintf("http://client-dynamic.imvu.com/api/shop/product.php?pid=%d", local), nil, true, true); string(body) != `{"products":[]}` {
|
||||
t.Fatalf("product.php %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutgoingChat(t *testing.T) {
|
||||
h, _ := newHarness(t)
|
||||
local := h.p.byProductID("blue-coat").local
|
||||
msg := func(text string) []byte {
|
||||
return []byte(fmt.Sprintf(`{"to": 0, "message": %q, "userId": 7, "chatId": 9}`, text))
|
||||
}
|
||||
out, drop := h.p.Outgoing("/chat/9", "messages", msg(fmt.Sprintf("*use 80 1 %d", local)))
|
||||
if drop || !bytes.Contains(out, []byte(`"message":"*use 80 1"`)) {
|
||||
t.Fatalf("%s %v", out, drop)
|
||||
}
|
||||
if got := h.p.wornLocal(user, []int64{1, 80}); len(got) != 1 || got[0] != local {
|
||||
t.Fatalf("worn %v", got)
|
||||
}
|
||||
// With a different IMVU outfit (changed elsewhere), community products aren't added to it.
|
||||
if got := h.p.wornLocal(user, []int64{80, 2}); len(got) != 0 {
|
||||
t.Fatalf("worn with another outfit %v", got)
|
||||
}
|
||||
if _, drop := h.p.Outgoing("/chat/9", "messages", msg(fmt.Sprintf("*use %d", local))); !drop {
|
||||
t.Fatal("a command with only community products was sent")
|
||||
}
|
||||
if out, _ := h.p.Outgoing("/chat/9", "messages", msg("hello")); !bytes.Equal(out, msg("hello")) {
|
||||
t.Fatal("plain chat changed")
|
||||
}
|
||||
if out, _ := h.p.Outgoing("room_occupancy/1", "occupants", []byte("x")); string(out) != "x" {
|
||||
t.Fatal("another queue changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackChangesAreServed(t *testing.T) {
|
||||
h, packs := newHarness(t)
|
||||
before := h.p.byProductID("blue-coat")
|
||||
os.WriteFile(filepath.Join(packs, "blue-coat", "files", "tex.png"), []byte("a new texture, longer"), 0o644)
|
||||
h.p.refresh(true)
|
||||
after := h.p.byProductID("blue-coat")
|
||||
if after.checksum == before.checksum || after.local != before.local {
|
||||
t.Fatalf("checksum %s -> %s, local %d -> %d", before.checksum, after.checksum, before.local, after.local)
|
||||
}
|
||||
os.RemoveAll(filepath.Join(packs, "blue-coat"))
|
||||
h.p.refresh(true)
|
||||
if h.p.byProductID("blue-coat") != nil {
|
||||
t.Fatal("a removed pack is still served")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user