CI / build (push) Successful in 34s
The shop's T5 filter (maturity_rating "t5") is answered from the hub's products, fetched so they can be
shown and tried on; buying T5 products adds them to the hub library, and product.php details for
community products are answered here.
The client sends some product IDs as floats ("2000000008.0"): product.php passed the worn community
product to IMVU that way. Every ID is now read either way, and products.Guard refuses any request to
IMVU that still names a local ID given out. T5DE_PROXY_DUMP logs request and response bodies.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
693 lines
19 KiB
Go
693 lines
19 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, 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/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
|
|
}
|
|
|
|
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 {
|
|
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")
|
|
}
|
|
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, 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
|
|
}
|
|
var products string
|
|
if json.Unmarshal(save["products"], &products) != nil {
|
|
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 {
|
|
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
|
|
}
|