Products: chat command filter, request signing, saved state
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
2190230f81
commit
0c4f6caec1
4 files changed
+313
No files matched your search
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user