Files
t5de-proxy/internal/products/intercept.go
T
dhkatzandClaude Opus 5.5 93fa2f5d90
CI / build (push) Successful in 28s
Room channel: other T5DE users' community products on their avatars
The proxy follows the client's IMQ chat subscriptions and tells the hub which chats it's in (and the room, from
chat.getOrMakeChat). For the other T5DE users the hub says are in a chat, it gives the client the community
products it has packs for, as *use and *remove lines from them, and adds them to the whole outfits they send.
It also takes community products out of the outfit the client posts to IMVU's chat participant endpoint, which
had been sending them to IMVU.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:14:44 -07:00

489 lines
14 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
}
if host == "api.imvu.com" && participantPath.MatchString(path) {
return p.interceptParticipant(x)
}
switch path {
case "/api/xmlrpc/chat.php":
return p.interceptChat(x)
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)
if m := userIDPattern.FindSubmatch(body); m != nil && p.Observer != nil {
if id, err := strconv.ParseInt(string(m[1]), 10, 64); err == nil {
p.Observer.Session(id)
}
}
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 {
p.reportOutfit(user, worn)
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)
p.reportOutfit(user, worn)
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
}