Products: chat command filter, request signing, saved state

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-29 02:49:10 -07:00
1 parent 2190230f81
commit 0c4f6caec1
4 files changed
+313

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
package products
import (
"encoding/json"
"strconv"
"strings"
)
// outfitCommands are the chat commands whose arguments are product IDs: putting products on and taking them off.
var outfitCommands = map[string]bool{
"*use": true, "*putOn": true, "*putOnOutfit": true, "*remove": true, "*takeOff": true,
}
// bodyProduct is the avatar body every full outfit starts from; a *use list with it is a whole outfit (the client
// re-sends its outfit that way every 30 s or so).
const bodyProduct = 80
// strippedCommand is a chat line with community products taken out of its outfit command.
type strippedCommand struct {
text string // the line to send instead
changed bool // products were taken out
empty bool // nothing is left to send
outfit bool // the line was a whole outfit (*use with the body)
products []int64 // the community products it named
}
// stripCommand takes products for which isCommunity is true out of an outfit command's arguments.
func stripCommand(text string, isCommunity func(int64) bool) strippedCommand {
fields := strings.Fields(text)
if len(fields) == 0 || !outfitCommands[fields[0]] {
return strippedCommand{text: text}
}
r := strippedCommand{text: text}
kept := []string{fields[0]}
for _, f := range fields[1:] {
id, err := strconv.ParseInt(f, 10, 64)
if err == nil && id == bodyProduct && fields[0] == "*use" {
r.outfit = true
}
if err == nil && isCommunity(id) {
r.products = append(r.products, id)
continue
}
kept = append(kept, f)
}
if len(r.products) == 0 {
return r
}
r.changed = true
r.empty = len(kept) == 1
r.text = strings.Join(kept, " ")
return r
}
// chatMessage is the JSON the client sends on a chat queue's "messages" mount.
type chatMessage struct {
fields map[string]json.RawMessage
text string
userID int64
}
func parseChatMessage(b []byte) (*chatMessage, bool) {
var fields map[string]json.RawMessage
if json.Unmarshal(b, &fields) != nil {
return nil, false
}
m := &chatMessage{fields: fields}
if json.Unmarshal(fields["message"], &m.text) != nil {
return nil, false
}
json.Unmarshal(fields["userId"], &m.userID)
return m, true
}
func (m *chatMessage) withText(text string) []byte {
b, _ := json.Marshal(text)
m.fields["message"] = b
out, _ := json.Marshal(m.fields)
return out
}
+46
View File
@@ -0,0 +1,46 @@
package products
import (
"fmt"
"testing"
)
func community(id int64) bool { return id >= 2000000000 }
func TestStripCommand(t *testing.T) {
for _, c := range []struct {
in, out string
empty, outfit bool
products string
}{
{"*use 80 123 2000000001 456", "*use 80 123 456", false, true, "[2000000001]"},
{"*use 2000000001", "*use", true, false, "[2000000001]"},
{"*putOnOutfit 5 2000000002 2000000003", "*putOnOutfit 5", false, false, "[2000000002 2000000003]"},
{"*remove 2000000001", "*remove", true, false, "[2000000001]"},
{"*use 80 123", "*use 80 123", false, true, "[]"},
{"hello 2000000001", "hello 2000000001", false, false, "[]"},
{"*imvu:setRoomState c...", "*imvu:setRoomState c...", false, false, "[]"},
} {
r := stripCommand(c.in, community)
if r.text != c.out || r.empty != c.empty || r.outfit != c.outfit || fmt.Sprint(r.products) != c.products {
t.Errorf("%q: got %+v", c.in, r)
}
if r.changed != (len(r.products) > 0) {
t.Errorf("%q: changed %v", c.in, r.changed)
}
}
}
func TestChatMessage(t *testing.T) {
m, ok := parseChatMessage([]byte(`{"to": 0, "message": "*use 80 2000000001", "userId": 392, "chatId": 77}`))
if !ok || m.text != "*use 80 2000000001" || m.userID != 392 {
t.Fatalf("%+v %v", m, ok)
}
out := string(m.withText("*use 80"))
if out != `{"chatId":77,"message":"*use 80","to":0,"userId":392}` {
t.Fatal(out)
}
if _, ok := parseChatMessage([]byte(`not json`)); ok {
t.Fatal("parsed non-JSON")
}
}
+68
View File
@@ -0,0 +1,68 @@
package products
import (
"crypto/md5"
"encoding/hex"
"net/http"
"regexp"
"sync"
)
// IMVU signs the client's requests: X-imvu-auth is md5(user ID + session security key + body), with the query
// string added for some calls. The proxy re-signs requests it changes (taking community products out), with the key
// from the login response. The key never leaves this machine.
func sign(user, key string, body []byte, query string, withQuery bool) string {
h := md5.New()
h.Write([]byte(user + key))
h.Write(body)
if withQuery {
h.Write([]byte(query))
}
return hex.EncodeToString(h.Sum(nil))
}
// keys holds each signed-in user's security key.
type keys struct {
mu sync.Mutex
byID map[string]string
}
var securityKeyPattern = regexp.MustCompile(`<name>securityKey</name>\s*<value>(?:<string>)?([0-9A-Za-z]+)`)
var userIDPattern = regexp.MustCompile(`<name>userId</name>\s*<value>(?:<int>|<i4>|<string>)?([0-9]+)`)
// learn takes the user's key from a login response (test.avatarInfoForLogin2).
func (k *keys) learn(body []byte) {
key, user := securityKeyPattern.FindSubmatch(body), userIDPattern.FindSubmatch(body)
if key == nil || user == nil {
return
}
k.mu.Lock()
defer k.mu.Unlock()
if k.byID == nil {
k.byID = map[string]string{}
}
k.byID[string(user[1])] = string(key[1])
}
// resign recomputes X-imvu-auth for a request whose body or query changed, in the scheme the original used. It
// reports false if it couldn't (no key, or the original signature didn't match either scheme).
func (k *keys) resign(h http.Header, oldBody []byte, oldQuery string, newBody []byte, newQuery string) bool {
auth, user := h.Get("X-imvu-auth"), h.Get("X-imvu-userid")
if auth == "" {
return true // not signed
}
k.mu.Lock()
key, ok := k.byID[user]
k.mu.Unlock()
if !ok {
return false
}
for _, withQuery := range []bool{false, true} {
if sign(user, key, oldBody, oldQuery, withQuery) == auth {
h.Set("X-imvu-auth", sign(user, key, newBody, newQuery, withQuery))
return true
}
}
return false
}
+119
View File
@@ -0,0 +1,119 @@
package products
import (
"encoding/json"
"errors"
"io/fs"
"os"
"path/filepath"
"sort"
"sync"
)
// FirstLocalID is where the client's numbers for community products start. IMVU's product IDs are far below
// (about 72 million in 2026), and the client's XML-RPC can't carry numbers past 2^31-1.
const FirstLocalID = 2_000_000_000
// state is what the proxy remembers between runs, in <data dir>/products.json: the local ID of every product it
// has served (so outfits keep naming the same product), and the community products each IMVU account wears (IMVU
// never sees them, so it can't remember them).
type state struct {
mu sync.Mutex
path string
data stateData
}
type stateData struct {
IDs map[string]int64 `json:"ids"` // product ID -> local ID
Worn map[string][]string `json:"worn"` // IMVU user ID -> product IDs
}
func loadState(path string) (*state, error) {
s := &state{path: path, data: stateData{IDs: map[string]int64{}, Worn: map[string][]string{}}}
b, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return s, nil
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(b, &s.data); err != nil {
return nil, err
}
if s.data.IDs == nil {
s.data.IDs = map[string]int64{}
}
if s.data.Worn == nil {
s.data.Worn = map[string][]string{}
}
return s, nil
}
// localID is the product's local ID, given one if it has none yet.
func (s *state) localID(product string) (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
if id, ok := s.data.IDs[product]; ok {
return id, nil
}
next := int64(FirstLocalID)
for _, id := range s.data.IDs {
next = max(next, id+1)
}
if next > 2_147_483_647 {
return 0, errors.New("products: out of local IDs")
}
s.data.IDs[product] = next
return next, s.saveLocked()
}
// worn is the community products the user wears.
func (s *state) worn(user string) []string {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string(nil), s.data.Worn[user]...)
}
// setWorn records the community products the user wears.
func (s *state) setWorn(user string, products []string) error {
products = append([]string(nil), products...)
sort.Strings(products)
s.mu.Lock()
defer s.mu.Unlock()
if equal(s.data.Worn[user], products) {
return nil
}
if len(products) == 0 {
delete(s.data.Worn, user)
} else {
s.data.Worn[user] = products
}
return s.saveLocked()
}
func equal(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func (s *state) saveLocked() error {
b, err := json.MarshalIndent(s.data, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
return os.Rename(tmp, s.path)
}