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]>
477 lines
13 KiB
Go
477 lines
13 KiB
Go
package products
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
|
"git.t5de.dev/t5de/t5de-proxy/internal/xmlrpc"
|
|
)
|
|
|
|
func localResponse(status int, contentType string, body []byte) *http.Response {
|
|
return proxy.LocalResponse(status, contentType, body)
|
|
}
|
|
|
|
// Intercept implements proxy.Interceptor.
|
|
func (p *Products) Intercept(x *proxy.Exchange) error {
|
|
host, path := strings.ToLower(x.URL.Hostname()), x.URL.Path
|
|
if strings.HasSuffix(host, ".imvu.com") && strings.HasPrefix(path, "/productdata/") {
|
|
if resp, ok := p.serveFile(path); ok {
|
|
x.Response = resp
|
|
}
|
|
return nil
|
|
}
|
|
if !strings.HasSuffix(host, ".imvu.com") {
|
|
return nil
|
|
}
|
|
switch path {
|
|
case "/api/xmlrpc/client.php":
|
|
return p.interceptXMLRPC(x)
|
|
case "/api/shop/product.php":
|
|
// 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) {
|
|
x.Response = localResponse(200, "application/json", []byte(`{"products":[]}`))
|
|
}
|
|
case "/api/base_look.php":
|
|
return p.interceptBaseLook(x)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Products) interceptXMLRPC(x *proxy.Exchange) error {
|
|
call, err := xmlrpc.ParseCall(x.Body)
|
|
if err != nil {
|
|
return nil // not ours to judge; IMVU answers
|
|
}
|
|
switch call.Method {
|
|
case "test.avatarInfoForLogin2":
|
|
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
|
|
p.keys.learn(body)
|
|
return p.addToLook(body)
|
|
}
|
|
case "test.inventoryProductBatch":
|
|
return p.inventoryProductBatch(x, call)
|
|
case "test.authorizeProducts2":
|
|
return p.authorizeProducts2(x, call)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// replaceBody sends call instead of the client's, re-signed.
|
|
func (p *Products) replaceBody(x *proxy.Exchange, call *xmlrpc.Call) error {
|
|
body := call.Marshal()
|
|
if !p.keys.resign(x.Request.Header, x.Body, x.URL.RawQuery, body, x.URL.RawQuery) {
|
|
return errNoKey
|
|
}
|
|
x.Body = body
|
|
return nil
|
|
}
|
|
|
|
// addToLook adds the user's community products to the outfit ("look") in a login response.
|
|
func (p *Products) addToLook(body []byte) ([]byte, error) {
|
|
m := userIDPattern.FindSubmatch(body)
|
|
if m == nil {
|
|
return body, nil
|
|
}
|
|
user := string(m[1])
|
|
r, err := xmlrpc.ParseResponse(body)
|
|
if err != nil || r.Fault != nil {
|
|
return body, err
|
|
}
|
|
changed := false
|
|
var walk func(v xmlrpc.Value) xmlrpc.Value
|
|
walk = func(v xmlrpc.Value) xmlrpc.Value {
|
|
switch v := v.(type) {
|
|
case xmlrpc.Struct:
|
|
for i, mem := range v {
|
|
if mem.Name == "look" {
|
|
if look, ok := productList(mem.Value); ok {
|
|
if worn := p.wornLocal(user, look); len(worn) > 0 {
|
|
v[i].Value = appendIDs(mem.Value.([]xmlrpc.Value), worn)
|
|
changed = true
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
v[i].Value = walk(mem.Value)
|
|
}
|
|
return v
|
|
case []xmlrpc.Value:
|
|
for i := range v {
|
|
v[i] = walk(v[i])
|
|
}
|
|
return v
|
|
}
|
|
return v
|
|
}
|
|
for i := range r.Params {
|
|
r.Params[i] = walk(r.Params[i])
|
|
}
|
|
if !changed {
|
|
return body, nil
|
|
}
|
|
return r.Marshal(), nil
|
|
}
|
|
|
|
func appendIDs(list []xmlrpc.Value, ids []int64) []xmlrpc.Value {
|
|
have := map[int64]bool{}
|
|
for _, v := range list {
|
|
if n, ok := v.(int64); ok {
|
|
have[n] = true
|
|
}
|
|
}
|
|
for _, id := range ids {
|
|
if !have[id] {
|
|
list = append(list, id)
|
|
}
|
|
}
|
|
return list
|
|
}
|
|
|
|
// productList reads a list of product IDs.
|
|
func productList(v xmlrpc.Value) ([]int64, bool) {
|
|
list, ok := v.([]xmlrpc.Value)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
ids := make([]int64, 0, len(list))
|
|
for _, v := range list {
|
|
n, ok := v.(int64)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
ids = append(ids, n)
|
|
}
|
|
return ids, true
|
|
}
|
|
|
|
// inventoryProductBatch answers product info for community products: the parent's info from IMVU with the
|
|
// product's own name, creator and ID, or (without a parent) info of its own.
|
|
func (p *Products) inventoryProductBatch(x *proxy.Exchange, call *xmlrpc.Call) error {
|
|
if len(call.Params) != 1 {
|
|
return nil
|
|
}
|
|
ids, ok := productList(call.Params[0])
|
|
if !ok {
|
|
return nil
|
|
}
|
|
var community []int64
|
|
var forward []xmlrpc.Value
|
|
asked := map[int64]bool{}
|
|
for _, id := range ids {
|
|
if !IsCommunity(id) {
|
|
if !asked[id] {
|
|
forward, asked[id] = append(forward, id), true
|
|
}
|
|
continue
|
|
}
|
|
community = append(community, id)
|
|
if s := p.lookup(id); s != nil && s.pack.Index.Parent > 0 && !asked[int64(s.pack.Index.Parent)] {
|
|
forward, asked[int64(s.pack.Index.Parent)] = append(forward, int64(s.pack.Index.Parent)), true
|
|
}
|
|
}
|
|
if len(community) == 0 {
|
|
return nil
|
|
}
|
|
// The answer, in the order asked, from IMVU's entries (by product ID) and our own.
|
|
answer := func(imvu map[int64]xmlrpc.Struct) []byte {
|
|
var out []xmlrpc.Value
|
|
for _, id := range ids {
|
|
if !IsCommunity(id) {
|
|
if e, ok := imvu[id]; ok {
|
|
out = append(out, e)
|
|
}
|
|
continue
|
|
}
|
|
if s := p.lookup(id); s != nil {
|
|
out = append(out, s.productInfo(imvu[int64(s.pack.Index.Parent)]))
|
|
}
|
|
}
|
|
return (&xmlrpc.Response{Params: []xmlrpc.Value{out}}).Marshal()
|
|
}
|
|
if len(forward) == 0 {
|
|
x.Response = localResponse(200, "text/xml", answer(nil))
|
|
return nil
|
|
}
|
|
if err := p.replaceBody(x, &xmlrpc.Call{Method: call.Method, Params: []xmlrpc.Value{forward}}); err != nil {
|
|
return err
|
|
}
|
|
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
|
|
r, err := xmlrpc.ParseResponse(body)
|
|
if err != nil || r.Fault != nil || len(r.Params) != 1 {
|
|
return body, err
|
|
}
|
|
list, _ := r.Params[0].([]xmlrpc.Value)
|
|
imvu := map[int64]xmlrpc.Struct{}
|
|
for _, v := range list {
|
|
if e, ok := v.(xmlrpc.Struct); ok {
|
|
if id, ok := e.Get("products_id"); ok {
|
|
if n, ok := id.(int64); ok {
|
|
imvu[n] = e
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return answer(imvu), nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// productInfo is a community product's entry for inventoryProductBatch, based on its parent's if there is one.
|
|
func (s *served) productInfo(parent xmlrpc.Struct) xmlrpc.Struct {
|
|
m := s.pack.Manifest
|
|
mature := "N"
|
|
if m.Rating == "adult" {
|
|
mature = "Y"
|
|
}
|
|
own := map[string]xmlrpc.Value{
|
|
"products_id": s.local,
|
|
"manufacturers_id": int64(0),
|
|
"manufacturers_name": m.Creator,
|
|
"products_name": m.Name,
|
|
"products_mature": mature,
|
|
"products_bundle": "",
|
|
"is_published": "1",
|
|
"is_purchasable": false,
|
|
}
|
|
var out xmlrpc.Struct
|
|
done := map[string]bool{}
|
|
for _, mem := range parent {
|
|
if v, ok := own[mem.Name]; ok {
|
|
out = append(out, xmlrpc.Member{Name: mem.Name, Value: v})
|
|
done[mem.Name] = true
|
|
} else {
|
|
out = append(out, mem) // cPath, products_image, ... from the parent
|
|
}
|
|
}
|
|
for _, name := range []string{"products_id", "manufacturers_id", "manufacturers_name", "products_name", "cPath",
|
|
"products_mature", "products_bundle", "products_image", "is_published", "is_purchasable"} {
|
|
if done[name] {
|
|
continue
|
|
}
|
|
v, ok := own[name]
|
|
if !ok {
|
|
v = "" // cPath and products_image, without a parent
|
|
}
|
|
if _, inParent := parent.Get(name); !inParent {
|
|
out = append(out, xmlrpc.Member{Name: name, Value: v})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// authColumns are authorizeProducts2's answer columns, as IMVU sends them.
|
|
var authColumns = []string{"userId", "parentProductId", "productId", "cflChecksum", "cflUrl", "sub_pids",
|
|
"usableDirect", "autoAuth", "usableDerivation", "usableTrial", "trialTimeout", "usablePeerReview", "info"}
|
|
|
|
// authorizeProducts2 answers permission and file addresses for community products, and asks IMVU for their
|
|
// parents' (the client needs the whole derivation chain).
|
|
func (p *Products) authorizeProducts2(x *proxy.Exchange, call *xmlrpc.Call) error {
|
|
if len(call.Params) != 1 {
|
|
return nil
|
|
}
|
|
args, ok := call.Params[0].(xmlrpc.Struct)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
authsValue, _ := args.Get("auths")
|
|
table, ok := authsValue.([]xmlrpc.Value)
|
|
if !ok || len(table) < 1 {
|
|
return nil
|
|
}
|
|
header, ok := table[0].([]xmlrpc.Value)
|
|
if !ok || len(header) != 2 || header[0] != "userId" || header[1] != "productId" {
|
|
return nil
|
|
}
|
|
type auth struct {
|
|
user, product int64
|
|
}
|
|
var community []auth
|
|
forward := []xmlrpc.Value{table[0]}
|
|
asked := map[auth]bool{}
|
|
for _, row := range table[1:] {
|
|
r, ok := row.([]xmlrpc.Value)
|
|
if !ok || len(r) != 2 {
|
|
return nil
|
|
}
|
|
user, ok1 := r[0].(int64)
|
|
product, ok2 := r[1].(int64)
|
|
if !ok1 || !ok2 {
|
|
return nil
|
|
}
|
|
if !IsCommunity(product) {
|
|
if !asked[auth{user, product}] {
|
|
forward, asked[auth{user, product}] = append(forward, row), true
|
|
}
|
|
continue
|
|
}
|
|
community = append(community, auth{user, product})
|
|
if s := p.lookup(product); s != nil && s.pack.Index.Parent > 0 {
|
|
a := auth{user, int64(s.pack.Index.Parent)}
|
|
if !asked[a] {
|
|
forward, asked[a] = append(forward, []xmlrpc.Value{user, a.product}), true
|
|
}
|
|
}
|
|
}
|
|
if len(community) == 0 {
|
|
return nil
|
|
}
|
|
ours := func(columns []xmlrpc.Value) []xmlrpc.Value {
|
|
var rows []xmlrpc.Value
|
|
for _, a := range community {
|
|
s := p.lookup(a.product)
|
|
if s == nil {
|
|
continue // unknown: no row, as IMVU does for products it can't authorize
|
|
}
|
|
values := map[string]xmlrpc.Value{
|
|
"userId": a.user, "parentProductId": int64(s.pack.Index.Parent), "productId": s.local,
|
|
"cflChecksum": s.checksum, "cflUrl": cflURL(s.local, s.pack.Manifest.Revision), "sub_pids": "",
|
|
"usableDirect": true, "autoAuth": true, "usableDerivation": int64(1), "usableTrial": true,
|
|
"trialTimeout": int64(40), "usablePeerReview": true, "info": "",
|
|
}
|
|
row := make([]xmlrpc.Value, len(columns))
|
|
for i, c := range columns {
|
|
name, _ := c.(string)
|
|
if v, ok := values[name]; ok {
|
|
row[i] = v
|
|
} else {
|
|
row[i] = ""
|
|
}
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
return rows
|
|
}
|
|
if len(forward) == 1 { // only community products, none deriving from IMVU's
|
|
columns := make([]xmlrpc.Value, len(authColumns))
|
|
for i, c := range authColumns {
|
|
columns[i] = c
|
|
}
|
|
out := append([]xmlrpc.Value{columns}, ours(columns)...)
|
|
x.Response = localResponse(200, "text/xml", (&xmlrpc.Response{Params: []xmlrpc.Value{out}}).Marshal())
|
|
return nil
|
|
}
|
|
changed := make(xmlrpc.Struct, len(args))
|
|
copy(changed, args)
|
|
for i := range changed {
|
|
if changed[i].Name == "auths" {
|
|
changed[i].Value = forward
|
|
}
|
|
}
|
|
if err := p.replaceBody(x, &xmlrpc.Call{Method: call.Method, Params: []xmlrpc.Value{changed}}); err != nil {
|
|
return err
|
|
}
|
|
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
|
|
r, err := xmlrpc.ParseResponse(body)
|
|
if err != nil || r.Fault != nil || len(r.Params) != 1 {
|
|
return body, err
|
|
}
|
|
rows, ok := r.Params[0].([]xmlrpc.Value)
|
|
if !ok || len(rows) == 0 {
|
|
return body, errors.New("products: unexpected authorizeProducts2 answer")
|
|
}
|
|
columns, ok := rows[0].([]xmlrpc.Value)
|
|
if !ok {
|
|
return body, errors.New("products: unexpected authorizeProducts2 columns")
|
|
}
|
|
r.Params[0] = append(rows, ours(columns)...)
|
|
return r.Marshal(), nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// interceptBaseLook keeps community products out of the outfit IMVU stores, and adds them back when the client
|
|
// loads it.
|
|
func (p *Products) interceptBaseLook(x *proxy.Exchange) error {
|
|
user := x.Request.Header.Get("X-imvu-userid")
|
|
if user == "" {
|
|
return nil
|
|
}
|
|
if len(x.Body) == 0 { // loading the outfit
|
|
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
|
|
var look map[string]json.RawMessage
|
|
if err := json.Unmarshal(body, &look); err != nil {
|
|
return body, err
|
|
}
|
|
var ids []int64
|
|
if err := json.Unmarshal(look["result"], &ids); err != nil {
|
|
return body, err
|
|
}
|
|
worn := p.wornLocal(user, ids)
|
|
if len(worn) == 0 {
|
|
return body, nil
|
|
}
|
|
have := map[int64]bool{}
|
|
for _, id := range ids {
|
|
have[id] = true
|
|
}
|
|
for _, id := range worn {
|
|
if !have[id] {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
look["result"], _ = json.Marshal(ids)
|
|
return json.Marshal(look)
|
|
}
|
|
return nil
|
|
}
|
|
// Saving it: {"pids": [...]}.
|
|
var save map[string]json.RawMessage
|
|
if err := json.Unmarshal(x.Body, &save); err != nil {
|
|
return nil
|
|
}
|
|
var ids []int64
|
|
if err := json.Unmarshal(save["pids"], &ids); err != nil {
|
|
return nil
|
|
}
|
|
var kept, community []int64
|
|
for _, id := range ids {
|
|
if IsCommunity(id) {
|
|
community = append(community, id)
|
|
} else {
|
|
kept = append(kept, id)
|
|
}
|
|
}
|
|
p.recordWorn(user, kept, community)
|
|
if len(community) == 0 {
|
|
return nil
|
|
}
|
|
p.logf("products: kept %d community products out of the outfit saved to IMVU", len(community))
|
|
save["pids"], _ = json.Marshal(kept)
|
|
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
|
|
}
|
|
|
|
// Outgoing implements proxy.IMQFilter: community products are taken out of outfit chat commands (IMVU and other
|
|
// people see the defaults), and whole outfits are remembered.
|
|
func (p *Products) Outgoing(queue, mount string, message []byte) ([]byte, bool) {
|
|
if !strings.HasPrefix(queue, "/chat/") || mount != "messages" {
|
|
return message, false
|
|
}
|
|
m, ok := parseChatMessage(message)
|
|
if !ok {
|
|
return message, false
|
|
}
|
|
r := stripCommand(m.text, IsCommunity)
|
|
if r.outfit && m.userID != 0 {
|
|
p.recordWorn(strconv.FormatInt(m.userID, 10), r.kept, r.products)
|
|
}
|
|
if !r.changed {
|
|
return message, false
|
|
}
|
|
command, _, _ := strings.Cut(r.text, " ")
|
|
if r.empty {
|
|
p.logf("products: answered a %s of only community products without sending it", command)
|
|
return nil, true
|
|
}
|
|
p.logf("products: kept %d community products out of a %s sent to IMVU", len(r.products), command)
|
|
return m.withText(r.text), false
|
|
}
|