CI / build (push) Successful in 28s
Hosted rooms' instance IDs start with their owner's IMVU account now, so the client asks about their products as the owner itself, and the mapping from 2100000000 is never used. Co-Authored-By: Claude Opus 5.5 <[email protected]>
768 lines
22 KiB
Go
768 lines
22 KiB
Go
package products
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"path/filepath"
|
|
"regexp"
|
|
"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)
|
|
}
|
|
if host == "api.imvu.com" {
|
|
if m := productPath.FindStringSubmatch(path); m != nil {
|
|
if id, ok := parseID(m[1]); ok && IsCommunity(id) {
|
|
x.Response = productAPIAnswer(path, m[2])
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
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, ok := parseID(x.URL.Query().Get("pid")); ok && IsCommunity(id) {
|
|
x.Response = localResponse(200, "application/json", []byte(`{"products":[]}`))
|
|
} else if len(x.Body) > 0 {
|
|
return p.interceptProductInfo(x)
|
|
}
|
|
case "/api/outfit_products.php":
|
|
return p.interceptOutfitProducts(x)
|
|
case "/api/find_bundle.php":
|
|
return p.interceptFindBundle(x)
|
|
case "/api/recently_added_inventory.php":
|
|
return p.interceptRecent(x)
|
|
case "/api/shop/search_st.php":
|
|
return p.interceptShopSearch(x)
|
|
case "/api/service/shop/svc_catalog_purchase.php":
|
|
return p.interceptPurchase(x)
|
|
case "/api/base_look.php":
|
|
return p.interceptBaseLook(x)
|
|
case "/api/service/inventory/inventory_delete.php":
|
|
return p.interceptHide(x)
|
|
case "/api/outfits.php":
|
|
return p.interceptOutfits(x)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// productPath is IMVU's REST API for a product (api.imvu.com/product/product-<id>[/<relation>]).
|
|
var productPath = regexp.MustCompile(`^/product/product-([0-9.]+)(/[a-z_]+)?$`)
|
|
|
|
// productAPIAnswer answers the REST API about a community product: no related products (uml_products, which the
|
|
// shop's recommender asks for when a product is tried on), and nothing else.
|
|
func productAPIAnswer(path, relation string) *http.Response {
|
|
id := "https://api.imvu.com" + path
|
|
if relation == "/uml_products" {
|
|
b, _ := json.Marshal(map[string]any{"status": "success", "id": id, "denormalized": map[string]any{
|
|
id: map[string]any{"data": map[string]any{"total_count": 0, "items": []any{}}, "relations": map[string]any{}}}})
|
|
return localResponse(200, "application/json", b)
|
|
}
|
|
return localResponse(404, "application/json", []byte(`{"status":"failure","error":"NOT_FOUND"}`))
|
|
}
|
|
|
|
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)
|
|
}
|
|
case "test.setRoomState":
|
|
if len(call.Params) == 1 {
|
|
return p.interceptSetRoomState(x, call)
|
|
}
|
|
case "test.getRoomState":
|
|
if len(call.Params) == 1 {
|
|
return p.interceptGetRoomState(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 {
|
|
switch n := v.(type) {
|
|
case int64:
|
|
ids = append(ids, n)
|
|
case float64:
|
|
id, ok := parseID(strconv.FormatFloat(n, 'f', -1, 64))
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
ids = append(ids, id)
|
|
default:
|
|
return nil, false
|
|
}
|
|
}
|
|
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")
|
|
}
|
|
all := append(rows, ours(columns)...)
|
|
r.Params[0] = all
|
|
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) {
|
|
out, drop := p.outgoing(queue, mount, message)
|
|
// A hosted room's message goes to the hub (without community products, as to IMVU), never to IMVU.
|
|
if p.Hosted != nil && p.Hosted.Send(queue, mount, out, message, drop) {
|
|
return nil, true
|
|
}
|
|
return out, drop
|
|
}
|
|
|
|
func (p *Products) outgoing(queue, mount string, message []byte) ([]byte, bool) {
|
|
if strings.HasPrefix(queue, "/chat/") && mount == "control" {
|
|
if out, changed := p.outgoingControlScene(message); changed {
|
|
return out, false
|
|
}
|
|
return message, false
|
|
}
|
|
if !strings.HasPrefix(queue, "/chat/") || mount != "messages" {
|
|
return message, false
|
|
}
|
|
m, ok := parseChatMessage(message)
|
|
if !ok {
|
|
return message, false
|
|
}
|
|
if strings.HasPrefix(m.text, setRoomStateCommand) {
|
|
if out, changed := p.outgoingScene(m); changed {
|
|
return out, false
|
|
}
|
|
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, ok := parseID(form.Get("pid"))
|
|
if !ok || !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
|
|
}
|
|
|
|
// interceptOutfits keeps community products out of saved outfits IMVU stores (POST /api/outfits.php, JSON with
|
|
// "products": "1,2,3"), remembering them under the outfit's ID, and adds them back when the client loads saved
|
|
// outfits (GET /api/outfits.php?batch=... or ?outfit_id=...: {"result": [{"user_outfits_id", "pids", ...}]}).
|
|
func (p *Products) interceptOutfits(x *proxy.Exchange) error {
|
|
if len(x.Body) > 0 {
|
|
var save map[string]json.RawMessage
|
|
if json.Unmarshal(x.Body, &save) != nil {
|
|
return nil
|
|
}
|
|
removed := p.outfitRemovals(save) // the outfit card's remove_pids
|
|
resave := func() error {
|
|
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
|
|
}
|
|
var products string
|
|
if json.Unmarshal(save["products"], &products) != nil {
|
|
if removed {
|
|
return resave()
|
|
}
|
|
return nil // not a save (e.g. set_default_outfit_id)
|
|
}
|
|
var kept []string
|
|
var community []string
|
|
stripped := 0
|
|
for _, f := range strings.Split(products, ",") {
|
|
id, ok := parseID(f)
|
|
if ok && IsCommunity(id) {
|
|
stripped++ // always kept from IMVU, even if its product isn't known anymore
|
|
if pid, ok := p.state.productID(id); ok {
|
|
community = append(community, pid)
|
|
}
|
|
continue
|
|
}
|
|
kept = append(kept, f)
|
|
}
|
|
if stripped == 0 {
|
|
if removed {
|
|
return resave()
|
|
}
|
|
return nil
|
|
}
|
|
save["products"], _ = json.Marshal(strings.Join(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
|
|
p.logf("products: kept %d community products out of an outfit saved to IMVU", stripped)
|
|
x.ModifyResponse = func(resp *http.Response, b []byte) ([]byte, error) {
|
|
var r struct {
|
|
Result struct {
|
|
OutfitID json.Number `json:"outfit_id"`
|
|
} `json:"result"`
|
|
}
|
|
if json.Unmarshal(b, &r) == nil && r.Result.OutfitID != "" {
|
|
if err := p.state.setOutfit(r.Result.OutfitID.String(), community); err != nil {
|
|
p.logf("products: remembering an outfit: %v", err)
|
|
}
|
|
}
|
|
return b, nil
|
|
}
|
|
return nil
|
|
}
|
|
x.ModifyResponse = func(resp *http.Response, b []byte) ([]byte, error) {
|
|
var r map[string]json.RawMessage
|
|
if json.Unmarshal(b, &r) != nil {
|
|
return b, nil
|
|
}
|
|
var outfits []map[string]json.RawMessage
|
|
if json.Unmarshal(r["result"], &outfits) != nil {
|
|
return b, nil
|
|
}
|
|
changed := false
|
|
for _, o := range outfits {
|
|
var id json.Number
|
|
if json.Unmarshal(o["user_outfits_id"], &id) != nil {
|
|
continue
|
|
}
|
|
products := p.state.outfit(id.String())
|
|
if len(products) == 0 {
|
|
continue
|
|
}
|
|
var pids []json.RawMessage
|
|
json.Unmarshal(o["pids"], &pids)
|
|
adult := false
|
|
for _, pid := range products {
|
|
if s := p.byProductID(pid); s != nil {
|
|
pids = append(pids, json.RawMessage(strconv.FormatInt(s.local, 10)))
|
|
adult = adult || s.pack.Manifest.Rating == "adult"
|
|
}
|
|
}
|
|
o["pids"], _ = json.Marshal(pids)
|
|
if adult {
|
|
var restricted string
|
|
json.Unmarshal(o["restricted"], &restricted)
|
|
if restricted == "" || restricted == "0" {
|
|
o["restricted"], _ = json.Marshal("1")
|
|
}
|
|
}
|
|
changed = true
|
|
}
|
|
if !changed {
|
|
return b, nil
|
|
}
|
|
r["result"], _ = json.Marshal(outfits)
|
|
return json.Marshal(r)
|
|
}
|
|
return nil
|
|
}
|