Files
t5de-proxy/internal/products/intercept.go
T
dhkatzandClaude Opus 5.5 0be2cd8f4e
CI / build (push) Successful in 44s
T5 furniture and poses in IMVU rooms (decorations)
Community products are taken out of rooms their owners save and broadcast, kept on the hub per room,
and added back when anyone with T5DE loads the room or gets its owner's broadcast; the hub's decor
notice resends the room with the new ones. Placements are stored with the hub's product ID.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:03:21 -07:00

799 lines
23 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
}
// In a room hosted by T5DE's hub, the client asks about the room's products as user 2100000000 (the first part
// of the room's ID): IMVU is asked about the room owner's instead, and answers go back as 2100000000.
owner := int64(0)
if p.HostedOwner != nil {
owner = p.HostedOwner()
}
hostedAsked := map[int64]bool{}
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 user == HostedOwnerID && owner > 0 {
user, hostedAsked[product] = owner, true
row = []xmlrpc.Value{user, product}
}
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 && len(hostedAsked) == 0 {
return nil
}
// hostedBack gives rows asked about as 2100000000 that user ID back.
hostedBack := func(columns []xmlrpc.Value, rows []xmlrpc.Value) {
userCol, productCol := -1, -1
for i, c := range columns {
switch c {
case "userId":
userCol = i
case "productId":
productCol = i
}
}
if userCol < 0 || productCol < 0 {
return
}
for _, row := range rows {
r, ok := row.([]xmlrpc.Value)
if !ok || len(r) <= userCol || len(r) <= productCol {
continue
}
if product, _ := r[productCol].(int64); r[userCol] == owner && hostedAsked[product] {
r[userCol] = int64(HostedOwnerID)
}
}
}
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)...)
hostedBack(columns, out[1:])
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)...)
hostedBack(columns, all[1:])
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 && !drop && p.Hosted.Send(queue, mount, out) {
return nil, true
}
return out, drop
}
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
}
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
}