CI / build (push) Successful in 25s
The client never subscribes to its user queue; the proxy now follows every IMQ connection and the signed-in account (from the login answer), and delivers the refresh event on /user/<id>. Checked live: removing a product from the library on the hub reloads the client's inventory. Co-Authored-By: Claude Opus 5.5 <[email protected]>
579 lines
16 KiB
Go
579 lines
16 KiB
Go
package products
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"path/filepath"
|
|
"sort"
|
|
"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)
|
|
case "/api/service/inventory/inventory_delete.php":
|
|
return p.interceptHide(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.mu.Lock()
|
|
p.user = string(m[1])
|
|
p.mu.Unlock()
|
|
}
|
|
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)
|
|
case "test.showInventory":
|
|
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
|
|
return p.addToInventory(body)
|
|
}
|
|
}
|
|
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": plain(m.Creator),
|
|
"products_name": plain(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
|
|
}
|
|
|
|
// plain keeps a name to characters the client's pages can show however they insert it: some insert creator and
|
|
// product names as HTML, in pages that can call into the client.
|
|
func plain(s string) string {
|
|
return strings.Map(func(r rune) rune {
|
|
switch r {
|
|
case '<', '>', '&', '"', '\'', '`':
|
|
return -1
|
|
}
|
|
return r
|
|
}, s)
|
|
}
|
|
|
|
// inventoryLocals is the community products in the inventory: local packs, and hub products in the library.
|
|
func (p *Products) inventoryLocals() []int64 {
|
|
p.refresh(false)
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
var ids []int64
|
|
for id, s := range p.byID {
|
|
if p.HubDir != "" && filepath.Dir(s.dir) == filepath.Clean(p.HubDir) && (p.Owned == nil || !p.Owned(id)) {
|
|
continue
|
|
}
|
|
ids = append(ids, s.local)
|
|
}
|
|
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
|
return ids
|
|
}
|
|
|
|
// addToInventory adds the community products to test.showInventory's answer (a list of product IDs).
|
|
func (p *Products) addToInventory(body []byte) ([]byte, error) {
|
|
r, err := xmlrpc.ParseResponse(body)
|
|
if err != nil || r.Fault != nil || len(r.Params) != 1 {
|
|
return body, nil
|
|
}
|
|
list, ok := r.Params[0].([]xmlrpc.Value)
|
|
if !ok {
|
|
return body, nil
|
|
}
|
|
have := map[int64]bool{}
|
|
for _, v := range list {
|
|
if n, ok := v.(int64); ok {
|
|
have[n] = true
|
|
}
|
|
}
|
|
added := 0
|
|
for _, id := range p.inventoryLocals() {
|
|
if !have[id] {
|
|
list = append(list, id)
|
|
added++
|
|
}
|
|
}
|
|
if added == 0 {
|
|
return body, nil
|
|
}
|
|
r.Params[0] = list
|
|
p.logf("products: %d community products in the inventory", added)
|
|
return r.Marshal(), nil
|
|
}
|
|
|
|
// interceptHide answers hiding a community product in the inventory itself (IMVU doesn't know it).
|
|
func (p *Products) interceptHide(x *proxy.Exchange) error {
|
|
form, err := url.ParseQuery(string(x.Body))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
pid, err := strconv.ParseInt(form.Get("pid"), 10, 64)
|
|
if err != nil || !IsCommunity(pid) {
|
|
return nil
|
|
}
|
|
if id, ok := p.state.productID(pid); ok && p.OnHide != nil {
|
|
p.OnHide(id)
|
|
}
|
|
x.Response = localResponse(200, "application/json", []byte(`{"success": true}`))
|
|
return nil
|
|
}
|