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:
dhkatzandClaude Opus 5.5 committed 2026-10-29 03:17:14 -07:00
1 parent b2eed27b99
commit 887e8a2364
7 files changed
+1177 -21

No files matched your search

+308
View File
@@ -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 &lt;Coat&gt;</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")
}
}