Compare commits

...
7 Commits
Author SHA1 Message Date
dhkatzandClaude Opus 5.5 92103098f8 Use the published t5de-api
CI / build (push) Successful in 43s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 03:20:03 -07:00
dhkatzandClaude Opus 5.5 0e5ecf54ae Document community products
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 03:17:30 -07:00
dhkatzandClaude Opus 5.5 887e8a2364 Serve community products from local packs
The proxy serves products from local packs (t5de-api's format) under local IDs from 2,000,000,000: product
info and permission answers (with the parent's derivation chain from IMVU), the product's CFL, and the shop's
bundle lookup. Community products are taken out of outfit saves and outfit chat commands before IMVU sees
them, remembered per account with the outfit they belong to, and added back when the client loads that
outfit. Changed requests are re-signed with the session key from the login response.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 03:17:14 -07:00
dhkatz b2eed27b99 Merge branch 'xmlrpc' into products 2026-10-29 02:49:10 -07:00
dhkatzandClaude Opus 5.5 0c4f6caec1 Products: chat command filter, request signing, saved state
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:49:10 -07:00
dhkatzandClaude Opus 5.5 bee95533c0 Add an XML-RPC codec
Parses and writes calls and responses with Python 2.7 xmlrpclib's and
IMVU's PHP server's types, keeping struct member order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:48:29 -07:00
dhkatzandClaude Opus 5.5 2190230f81 Frame-by-frame IMQ with an outgoing filter; HTTP interceptor hook
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:47:48 -07:00
21 changed files with 2819 additions and 27 deletions

No files matched your search

+3
View File
@@ -12,6 +12,9 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
# T5DE's modules (t5de-api) come straight from its Gitea, not through Go's public module proxy.
GOPRIVATE: git.t5de.dev
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
+8
View File
@@ -24,3 +24,11 @@ in the `t5de` repository (git.t5de.dev/t5de/t5de, usually checked out beside thi
- t5de's `tools/drive.py` starts the built client, takes screenshots, clicks, and sends `imvu:` commands - t5de's `tools/drive.py` starts the built client, takes screenshots, clicks, and sends `imvu:` commands
(`showMode?mode=shop`, ...), so the proxy can be tested in the real client without a person; the test (`showMode?mode=shop`, ...), so the proxy can be tested in the real client without a person; the test
account's saved login signs in by itself. The log is `%TEMP%\t5de-proxy.log`. account's saved login signs in by itself. The log is `%TEMP%\t5de-proxy.log`.
- Community products (README): `internal/products`. What the client needs was worked out from captures
(t5de-hub's `docs/imq-rooms.md` and this repo's README): a product's CFL holds `index.xml` (deriving from a
parent with `__DATAIMPORT product://<id>/index.xml`), materials and textures; `_contents.json` would make the
client fetch files separately, so packs don't use it. Outfit saves happen only from some modes (Dress Up, not
rooms). Test packs (never committed: they're built on IMVU products' templates) live outside the repo; tests use
synthetic files.
- Debugging: `-trace` (or `T5DE_PROXY_TRACE=1`) logs full URLs and IMQ frame types; changes to community products
are logged as `products: ...`.
+23
View File
@@ -12,6 +12,8 @@ connects to IMVU through it:
gateway over TLS. gateway over TLS.
- **IMVU's reporting endpoints refused**, as T5DE's patches already do in the client. - **IMVU's reporting endpoints refused**, as T5DE's patches already do in the client.
- **Community products** from local packs (t5de-api's pack format), kept away from IMVU (below).
If the proxy isn't there or stops, the client connects directly, as before. If the proxy isn't there or stops, the client connects directly, as before.
Unofficial: not made by or affiliated with IMVU. Unofficial: not made by or affiliated with IMVU.
@@ -32,6 +34,27 @@ The client starts it as `t5de-proxy -parent <pid> -ready <file> -log %TEMP%\t5de
address and token to the ready file once listening, and exits with the client. Setting `T5DE_PROXY_TRACE=1` address and token to the ready file once listening, and exits with the client. Setting `T5DE_PROXY_TRACE=1`
before starting the client adds full URLs (with queries) and every IMQ frame to the log. before starting the client adds full URLs (with queries) and every IMQ frame to the log.
## Community products
Phase 1 of T5DE's community products (design: t5de-hub's `docs/design.md`): products in local packs, a folder
each, in `%APPDATA%\T5DE\packs` and the folders in `T5DE_PACKS` (separated by `;`). Each product gets a local ID
from 2,000,000,000 up, kept in `%APPDATA%\T5DE\proxy\products.json`. For those IDs the proxy answers the client
itself:
- `test.inventoryProductBatch` (product info) and `test.authorizeProducts2` (permission, the CFL's address and
checksum): for a product deriving from an IMVU product, IMVU is asked about the parent and the proxy adds its own
entry (the client needs the parent's derivation chain).
- `productdata/<local ID>/<revision>`: the pack built into a CFL.
- `api/shop/product.php` (putting a product on asks whether it's a bundle).
IMVU never sees community products: they're taken out of outfit saves (`api/base_look.php`) and outfit chat
commands (`*use`, `*putOn`, `*putOnOutfit`, `*remove`, `*takeOff` on `/chat/` queues; a command left empty is
answered by the proxy), so IMVU and everyone else see the defaults. The proxy remembers each account's community
products with the outfit they were saved with, and adds them back when the client loads that same outfit
(`base_look.php`, and `look` in the login response); an outfit changed elsewhere comes back without them.
Requests it changes are re-signed (`X-imvu-auth`) with the session key from the login response, which never
leaves the machine.
## Building ## Building
go test ./... go test ./...
+38 -3
View File
@@ -18,10 +18,13 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"path/filepath"
"strings"
"time" "time"
"git.t5de.dev/t5de/t5de-proxy/internal/buildinfo" "git.t5de.dev/t5de/t5de-proxy/internal/buildinfo"
"git.t5de.dev/t5de/t5de-proxy/internal/parent" "git.t5de.dev/t5de/t5de-proxy/internal/parent"
"git.t5de.dev/t5de/t5de-proxy/internal/products"
"git.t5de.dev/t5de/t5de-proxy/internal/proxy" "git.t5de.dev/t5de/t5de-proxy/internal/proxy"
) )
@@ -31,6 +34,9 @@ func main() {
parentPID := flag.Int("parent", 0, "exit when this process exits") parentPID := flag.Int("parent", 0, "exit when this process exits")
logFile := flag.String("log", "", "log file (default: standard error)") logFile := flag.String("log", "", "log file (default: standard error)")
trace := flag.Bool("trace", false, "log full URLs and every IMQ frame") trace := flag.Bool("trace", false, "log full URLs and every IMQ frame")
packs := flag.String("packs", "", "community product packs: directories of packs, separated by "+string(filepath.ListSeparator)+
" (default: %APPDATA%/T5DE/packs and $T5DE_PACKS)")
data := flag.String("data", "", "where the proxy keeps its state (default: %APPDATA%/T5DE/proxy)")
version := flag.Bool("version", false, "print the version and exit") version := flag.Bool("version", false, "print the version and exit")
flag.Parse() flag.Parse()
if *version { if *version {
@@ -48,13 +54,35 @@ func main() {
out = f out = f
} }
logger := log.New(out, "", log.Ldate|log.Ltime|log.Lmicroseconds) logger := log.New(out, "", log.Ldate|log.Ltime|log.Lmicroseconds)
if err := run(logger, *listen, *ready, *parentPID, *trace); err != nil { if err := run(logger, *listen, *ready, *parentPID, *trace, packDirs(*packs), dataDir(*data)); err != nil {
logger.Printf("t5de-proxy: %v", err) logger.Printf("t5de-proxy: %v", err)
os.Exit(1) os.Exit(1)
} }
} }
func run(logger *log.Logger, listen, ready string, parentPID int, trace bool) error { // packDirs is where community product packs are: the -packs flag, else T5DE's packs folder and $T5DE_PACKS.
func packDirs(flagValue string) []string {
if flagValue != "" {
return filepath.SplitList(flagValue)
}
var dirs []string
if config, err := os.UserConfigDir(); err == nil {
dirs = append(dirs, filepath.Join(config, "T5DE", "packs"))
}
return append(dirs, filepath.SplitList(os.Getenv("T5DE_PACKS"))...)
}
func dataDir(flagValue string) string {
if flagValue != "" {
return flagValue
}
if config, err := os.UserConfigDir(); err == nil {
return filepath.Join(config, "T5DE", "proxy")
}
return "."
}
func run(logger *log.Logger, listen, ready string, parentPID int, trace bool, packs []string, data string) error {
secret := make([]byte, 24) secret := make([]byte, 24)
if _, err := rand.Read(secret); err != nil { if _, err := rand.Read(secret); err != nil {
return err return err
@@ -78,8 +106,15 @@ func run(logger *log.Logger, listen, ready string, parentPID int, trace bool) er
os.Exit(0) os.Exit(0)
}() }()
} }
handler := &proxy.Server{Token: token, Log: logger, Trace: trace}
if prods, err := products.New(packs, data, logger.Printf); err != nil {
logger.Printf("community products are off: %v", err)
} else {
handler.Interceptor, handler.IMQFilter = prods, prods
logger.Printf("community products from %s", strings.Join(packs, ", "))
}
server := &http.Server{ server := &http.Server{
Handler: &proxy.Server{Token: token, Log: logger, Trace: trace}, Handler: handler,
ReadHeaderTimeout: 30 * time.Second, ReadHeaderTimeout: 30 * time.Second,
IdleTimeout: 2 * time.Minute, IdleTimeout: 2 * time.Minute,
ErrorLog: logger, ErrorLog: logger,
+2
View File
@@ -1,3 +1,5 @@
module git.t5de.dev/t5de/t5de-proxy module git.t5de.dev/t5de/t5de-proxy
go 1.27.0 go 1.27.0
require git.t5de.dev/t5de/t5de-api v0.0.0-20261029101713-106b4fcc6064
+2
View File
@@ -0,0 +1,2 @@
git.t5de.dev/t5de/t5de-api v0.0.0-20261029101713-106b4fcc6064 h1:UdRdoSTTZHjUTW7wF9+EVJ7UFgBktXrls3FsMKiOp2Y=
git.t5de.dev/t5de/t5de-api v0.0.0-20261029101713-106b4fcc6064/go.mod h1:mRXJdl43TzJN3JlUeVhi6BSqle37+ALmqzxqTYchXLw=
+9 -1
View File
@@ -40,10 +40,11 @@ func TypeName(t uint64) string {
return fmt.Sprintf("type %d", t) return fmt.Sprintf("type %d", t)
} }
// Frame is one message: its type and the size of its data. // Frame is one message: its type and data (Size is len(Data)).
type Frame struct { type Frame struct {
Type uint64 Type uint64
Size int Size int
Data []byte
} }
// ErrMalformed means the stream isn't IMQ framing; a Reader stops decoding after it. // ErrMalformed means the stream isn't IMQ framing; a Reader stops decoding after it.
@@ -76,6 +77,12 @@ func (r *Reader) Write(data []byte) ([]Frame, error) {
} }
} }
// Next reads the frame at the start of b: the frame and its length in b, a length of 0 if b holds only part of it,
// or ErrMalformed.
func Next(b []byte) (Frame, int, error) {
return parse(b)
}
// maxFrame bounds a frame's size, so a stream that isn't IMQ isn't buffered without end. // maxFrame bounds a frame's size, so a stream that isn't IMQ isn't buffered without end.
const maxFrame = 16 << 20 const maxFrame = 16 << 20
@@ -116,6 +123,7 @@ func parse(b []byte) (Frame, int, error) {
return f, 0, nil return f, 0, nil
} }
f.Size = int(size) f.Size = int(size)
f.Data = b[i : i+int(size) : i+int(size)]
return f, i + int(size), nil return f, i + int(size), nil
default: default:
return f, 0, ErrMalformed return f, 0, ErrMalformed
+20 -2
View File
@@ -36,12 +36,12 @@ func TestReaderSplitsFrames(t *testing.T) {
} }
got = append(got, frames...) got = append(got, frames...)
} }
want := []Frame{{1302, 5}, {3103, 300}, {1301, 0}} want := []Frame{{1302, 5, []byte("hello")}, {3103, 300, bytes.Repeat([]byte{1}, 300)}, {1301, 0, nil}}
if len(got) != len(want) { if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want) t.Fatalf("got %v, want %v", got, want)
} }
for i := range want { for i := range want {
if got[i] != want[i] { if got[i].Type != want[i].Type || got[i].Size != want[i].Size || !bytes.Equal(got[i].Data, want[i].Data) {
t.Fatalf("got %v, want %v", got, want) t.Fatalf("got %v, want %v", got, want)
} }
} }
@@ -63,3 +63,21 @@ func TestTypeName(t *testing.T) {
t.Fatal(TypeName(3104), TypeName(7)) t.Fatal(TypeName(3104), TypeName(7))
} }
} }
func TestSendMessageRoundTrip(t *testing.T) {
m := SendMessage{OpID: 300, Queue: "/chat/1", Mount: "messages", Message: []byte(`{"message": "*use 80 1"}`)}
data := m.Marshal()
got, err := ParseSendMessage(data)
if err != nil || got.OpID != 300 || got.Queue != "/chat/1" || got.Mount != "messages" || string(got.Message) != string(m.Message) {
t.Fatalf("%+v %v", got, err)
}
// A frame of it reads back as one frame with the same data.
var r Reader
frames, err := r.Write(EncodeFrame(C2gSendMessage, data))
if err != nil || len(frames) != 1 || frames[0].Type != C2gSendMessage || !bytes.Equal(frames[0].Data, data) {
t.Fatalf("%v %v", frames, err)
}
if _, err := ParseSendMessage(Result(1, 0)); err == nil {
t.Fatal("a G2cResult parsed as a C2gSendMessage")
}
}
+120
View File
@@ -0,0 +1,120 @@
package imq
import "errors"
// Message types the proxy reads or writes.
const (
C2gSendMessage = 1304
G2cResult = 3102
)
// SendMessage is a C2gSendMessage: a message the client sends to a queue's mount.
type SendMessage struct {
OpID uint64
Queue string
Mount string
Message []byte
}
// field is one protobuf field: its number and either a varint or bytes.
type field struct {
num uint64
varint uint64
bytes []byte
isLen bool
}
func fields(b []byte) ([]field, error) {
var out []field
for i := 0; i < len(b); {
key, n := varint(b[i:])
if n <= 0 {
return nil, ErrMalformed
}
i += n
f := field{num: key >> 3}
switch key & 7 {
case 0:
v, n := varint(b[i:])
if n <= 0 {
return nil, ErrMalformed
}
f.varint, i = v, i+n
case 2:
l, n := varint(b[i:])
if n <= 0 || l > uint64(len(b)-i-n) {
return nil, ErrMalformed
}
i += n
f.bytes, f.isLen, i = b[i:i+int(l)], true, i+int(l)
default:
return nil, ErrMalformed
}
out = append(out, f)
}
return out, nil
}
// ParseSendMessage reads a C2gSendMessage's data.
func ParseSendMessage(data []byte) (SendMessage, error) {
fs, err := fields(data)
if err != nil {
return SendMessage{}, err
}
var m SendMessage
seen := 0
for _, f := range fs {
switch {
case f.num == 1 && !f.isLen:
m.OpID, seen = f.varint, seen|1
case f.num == 2 && f.isLen:
m.Queue, seen = string(f.bytes), seen|2
case f.num == 3 && f.isLen:
m.Mount, seen = string(f.bytes), seen|4
case f.num == 4 && f.isLen:
m.Message, seen = f.bytes, seen|8
default:
return SendMessage{}, errors.New("imq: unexpected field in C2gSendMessage")
}
}
if seen != 15 {
return SendMessage{}, errors.New("imq: C2gSendMessage is missing fields")
}
return m, nil
}
// Marshal encodes the message's data, fields in the client's order.
func (m SendMessage) Marshal() []byte {
var b []byte
b = appendVarintField(b, 1, m.OpID)
b = appendBytesField(b, 2, []byte(m.Queue))
b = appendBytesField(b, 3, []byte(m.Mount))
return appendBytesField(b, 4, m.Message)
}
// Result encodes a G2cResult's data: the answer to the operation opID (status 0 is success).
func Result(opID, status uint64) []byte {
return appendVarintField(appendVarintField(nil, 1, opID), 2, status)
}
// EncodeFrame wraps a message's data in a frame of the given type.
func EncodeFrame(t uint64, data []byte) []byte {
return appendBytesField(appendVarintField(nil, 1, t), 2, data)
}
func appendVarint(b []byte, v uint64) []byte {
for v >= 0x80 {
b = append(b, byte(v)|0x80)
v >>= 7
}
return append(b, byte(v))
}
func appendVarintField(b []byte, num, v uint64) []byte {
return appendVarint(appendVarint(b, num<<3), v)
}
func appendBytesField(b []byte, num uint64, v []byte) []byte {
b = appendVarint(appendVarint(b, num<<3|2), uint64(len(v)))
return append(b, v...)
}
+84
View File
@@ -0,0 +1,84 @@
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
kept []int64 // the other 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
}
if err == nil {
r.kept = append(r.kept, id)
}
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")
}
}
+476
View File
@@ -0,0 +1,476 @@
package products
import (
"encoding/json"
"errors"
"net/http"
"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
}
switch path {
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, err := strconv.ParseInt(x.URL.Query().Get("pid"), 10, 64); err == nil && IsCommunity(id) {
x.Response = localResponse(200, "application/json", []byte(`{"products":[]}`))
}
case "/api/base_look.php":
return p.interceptBaseLook(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)
return p.addToLook(body)
}
case "test.inventoryProductBatch":
return p.inventoryProductBatch(x, call)
case "test.authorizeProducts2":
return p.authorizeProducts2(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 {
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 {
n, ok := v.(int64)
if !ok {
return nil, false
}
ids = append(ids, n)
}
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": m.Creator,
"products_name": 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)
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
}
+282
View File
@@ -0,0 +1,282 @@
// Package products serves community products to the client from local packs (t5de-api's pack format), and keeps
// them away from IMVU.
//
// The client knows products by number, so each product gets a local ID from FirstLocalID up. For those IDs the
// proxy answers the client itself: product info (test.inventoryProductBatch), permission and file addresses
// (test.authorizeProducts2), the shop's product lookup, and the product's CFL. A product deriving from an IMVU
// product (its index.xml imports the parent's) gets the parent's answers from IMVU, asked for the parent. Outfits
// keep community products for the client but not for IMVU: they're taken out of outfit saves (base_look.php) and
// outfit chat commands (*use ...), remembered per account, and added back when the client loads the outfit.
package products
import (
"bytes"
"crypto/md5"
"encoding/hex"
"errors"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"git.t5de.dev/t5de/t5de-api/cfl"
"git.t5de.dev/t5de/t5de-api/pack"
)
// Products is the proxy's community products: an Interceptor for HTTP and an IMQFilter for chat.
type Products struct {
dirs []string
state *state
keys keys
logf func(format string, args ...any)
mu sync.Mutex
scanned time.Time
byLocal map[int64]*served
byID map[string]*served
problems map[string]string // pack directory -> last error logged
}
// served is a pack ready to serve.
type served struct {
dir string
fingerprint string
pack *pack.Pack
local int64
cfl []byte
checksum string // md5 of cfl, as IMVU's cflChecksum
}
// New serves the packs in dirs (each pack a subdirectory), remembering its state in dataDir.
func New(dirs []string, dataDir string, logf func(format string, args ...any)) (*Products, error) {
st, err := loadState(filepath.Join(dataDir, "products.json"))
if err != nil {
return nil, err
}
if logf == nil {
logf = func(string, ...any) {}
}
p := &Products{dirs: dirs, state: st, logf: logf, byLocal: map[int64]*served{}, byID: map[string]*served{},
problems: map[string]string{}}
p.refresh(true)
return p, nil
}
// IsCommunity reports whether the client's product ID is a community product's local ID.
func IsCommunity(id int64) bool { return id >= FirstLocalID }
// lookup is the pack served under a local ID, after picking up changed packs.
func (p *Products) lookup(local int64) *served {
p.refresh(false)
p.mu.Lock()
defer p.mu.Unlock()
return p.byLocal[local]
}
func (p *Products) byProductID(id string) *served {
p.refresh(false)
p.mu.Lock()
defer p.mu.Unlock()
return p.byID[id]
}
// refresh reloads packs that changed on disk, at most once a second unless forced. Creators editing a pack see the
// new files the next time the client loads the product.
func (p *Products) refresh(force bool) {
p.mu.Lock()
defer p.mu.Unlock()
if !force && time.Since(p.scanned) < time.Second {
return
}
p.scanned = time.Now()
seen := map[string]bool{}
for _, root := range p.dirs {
entries, err := os.ReadDir(root)
if err != nil {
continue
}
for _, e := range entries {
dir := filepath.Join(root, e.Name())
if !e.IsDir() {
continue
}
if _, err := os.Stat(filepath.Join(dir, "t5de-product.json")); err != nil {
continue
}
fp := fingerprint(dir)
if old := p.findDirLocked(dir); old != nil && old.fingerprint == fp {
seen[old.pack.Manifest.ID] = true
continue
}
s, err := p.loadLocked(dir, fp)
if err != nil {
if p.problems[dir] != err.Error() {
p.logf("products: %s: %v", dir, err)
p.problems[dir] = err.Error()
}
if old := p.findDirLocked(dir); old != nil {
seen[old.pack.Manifest.ID] = true // keep serving the last good one
}
continue
}
delete(p.problems, dir)
if other, ok := p.byID[s.pack.Manifest.ID]; ok && other.dir != dir && seen[s.pack.Manifest.ID] {
p.logf("products: %s: product %q is also in %s; ignoring this one", dir, s.pack.Manifest.ID, other.dir)
continue
}
p.byID[s.pack.Manifest.ID] = s
p.byLocal[s.local] = s
seen[s.pack.Manifest.ID] = true
p.logf("products: serving %q revision %d (%s) as %d", s.pack.Manifest.ID, s.pack.Manifest.Revision, s.pack.Manifest.Name, s.local)
}
}
for id, s := range p.byID {
if !seen[id] {
delete(p.byID, id)
delete(p.byLocal, s.local)
p.logf("products: %q is gone", id)
}
}
}
func (p *Products) findDirLocked(dir string) *served {
for _, s := range p.byID {
if s.dir == dir {
return s
}
}
return nil
}
func (p *Products) loadLocked(dir, fp string) (*served, error) {
pk, err := pack.Load(dir)
if err != nil {
return nil, err
}
local, err := p.state.localID(pk.Manifest.ID)
if err != nil {
return nil, err
}
entries := make([]cfl.Entry, 0, len(pk.Files))
for _, f := range pk.Files {
data := f.Data
if f.Name == "index.xml" {
data = withProductName(data, pk.Manifest.Name)
}
entries = append(entries, cfl.Entry{Name: f.Name, Data: data})
}
var buf bytes.Buffer
if err := cfl.Write(&buf, entries); err != nil {
return nil, err
}
sum := md5.Sum(buf.Bytes())
return &served{dir: dir, fingerprint: fp, pack: pk, local: local, cfl: buf.Bytes(), checksum: hex.EncodeToString(sum[:])}, nil
}
// fingerprint changes when anything in the pack directory (or its files/ directory) changes.
func fingerprint(dir string) string {
var b strings.Builder
filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if info, err := d.Info(); err == nil {
fmt.Fprintf(&b, "%s|%d|%d\n", path, info.Size(), info.ModTime().UnixNano())
}
return nil
})
return b.String()
}
var productNamePattern = regexp.MustCompile(`(?s)<ProductName>.*?</ProductName>`)
// withProductName puts the pack's name in index.xml, so the client shows it.
func withProductName(index []byte, name string) []byte {
var esc bytes.Buffer
xmlEscape(&esc, name)
repl := []byte("<ProductName>" + esc.String() + "</ProductName>")
if productNamePattern.Match(index) {
return productNamePattern.ReplaceAllLiteral(index, repl)
}
return index
}
func xmlEscape(b *bytes.Buffer, s string) {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&apos;")
b.WriteString(r.Replace(s))
}
// productdata matches IMVU's product file addresses: /productdata/<id>/<revision>[/<file>].
var productdataPath = regexp.MustCompile(`^/productdata/([0-9]+)/([0-9]+)(?:/(.+))?$`)
// cflURL is where the client fetches a community product's CFL: IMVU's address form, answered by the proxy.
func cflURL(local int64, revision int) string {
return fmt.Sprintf("http://userimages-akm.imvu.com/productdata/%d/%d", local, revision)
}
// serveFile answers a request for a community product's files.
func (p *Products) serveFile(path string) (*http.Response, bool) {
m := productdataPath.FindStringSubmatch(path)
if m == nil {
return nil, false
}
local, _ := strconv.ParseInt(m[1], 10, 64)
if !IsCommunity(local) {
return nil, false
}
s := p.lookup(local)
rev, _ := strconv.Atoi(m[2])
if s == nil || rev != s.pack.Manifest.Revision {
return notFound(), true
}
if m[3] == "" {
resp := localResponse(200, "application/octet-stream", s.cfl)
resp.Header.Set("Content-MD5", s.checksum)
return resp, true
}
for _, f := range s.pack.Files {
if f.Name == m[3] {
return localResponse(200, "application/octet-stream", f.Data), true
}
}
return notFound(), true
}
func notFound() *http.Response {
return localResponse(404, "text/plain", []byte("t5de: no such community product file"))
}
// wornLocal is the user's community products (local IDs, still served) that go with the IMVU outfit look.
func (p *Products) wornLocal(user string, look []int64) []int64 {
var ids []int64
for _, id := range p.state.worn(user, look) {
if s := p.byProductID(id); s != nil {
ids = append(ids, s.local)
}
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}
// recordWorn remembers the user's outfit: the IMVU products sent to IMVU, and the community products (local IDs)
// taken out.
func (p *Products) recordWorn(user string, imvu, locals []int64) {
var ids []string
for _, local := range locals {
if id, ok := p.state.productID(local); ok {
ids = append(ids, id)
}
}
if err := p.state.setWorn(user, imvu, ids); err != nil {
p.logf("products: saving what %s wears: %v", user, err)
}
}
var errNoKey = errors.New("products: can't re-sign the request (no session key yet)")
+308
View File
@@ -0,0 +1,308 @@
package products
import (
"bufio"
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"git.t5de.dev/t5de/t5de-api/cfl"
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
"git.t5de.dev/t5de/t5de-proxy/internal/xmlrpc"
)
const (
user = "7"
key = "0123abcd"
parent = 500 // the IMVU product the test pack derives from
token = "tok"
)
// writePack makes a pack deriving from IMVU product 500.
func writePack(t *testing.T, root, id string, revision int) string {
dir := filepath.Join(root, id)
os.MkdirAll(filepath.Join(dir, "files"), 0o755)
manifest := fmt.Sprintf(`{"format": 1, "id": %q, "revision": %d, "name": "Blue <Coat>", "creator": "Tester", "rating": "general"}`, id, revision)
os.WriteFile(filepath.Join(dir, "t5de-product.json"), []byte(manifest), 0o644)
index := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8" ?><Template><ProductRating>GA</ProductRating><__DATAIMPORT>product://%d/index.xml</__DATAIMPORT><ProductName>Old Name</ProductName></Template>`, parent)
os.WriteFile(filepath.Join(dir, "files", "index.xml"), []byte(index), 0o644)
os.WriteFile(filepath.Join(dir, "files", "tex.png"), []byte("not really a png"), 0o644)
return dir
}
// fakeIMVU answers the calls the client makes, checking signatures, and records what it was asked.
type fakeIMVU struct {
t *testing.T
mu sync.Mutex
asked map[string][]string // method or path -> bodies
look []int64
}
func (f *fakeIMVU) record(name string, body []byte) {
f.mu.Lock()
defer f.mu.Unlock()
f.asked[name] = append(f.asked[name], string(body))
}
func (f *fakeIMVU) RoundTrip(r *http.Request) (*http.Response, error) {
var body []byte
if r.Body != nil {
body, _ = io.ReadAll(r.Body)
}
reply := func(s string) (*http.Response, error) {
return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(s)), Request: r}, nil
}
checkSigned := func(withQuery bool) {
if r.Header.Get("X-imvu-auth") != sign(user, key, body, r.URL.RawQuery, withQuery) {
f.t.Errorf("%s: bad signature", r.URL)
}
}
switch r.URL.Path {
case "/api/base_look.php":
if len(body) > 0 {
checkSigned(true)
f.record("save look", body)
var save struct{ Pids []int64 }
json.Unmarshal(body, &save)
f.look = save.Pids
return reply(`{"success": true}`)
}
b, _ := json.Marshal(map[string]any{"result": f.look})
return reply(string(b))
case "/api/xmlrpc/client.php":
call, err := xmlrpc.ParseCall(body)
if err != nil {
f.t.Fatalf("bad call: %v", err)
}
f.record(call.Method, body)
switch call.Method {
case "test.avatarInfoForLogin2":
return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{xmlrpc.Struct{
{Name: "userId", Value: int64(7)}, {Name: "securityKey", Value: key},
{Name: "look", Value: []xmlrpc.Value{int64(80), int64(1)}}}}}).Marshal()))
case "test.inventoryProductBatch":
checkSigned(false)
var out []xmlrpc.Value
for _, v := range call.Params[0].([]xmlrpc.Value) {
out = append(out, xmlrpc.Struct{{Name: "products_id", Value: v}, {Name: "products_name", Value: "IMVU's"},
{Name: "cPath", Value: "106_40"}, {Name: "manufacturers_name", Value: "IMVU Creator"}})
}
return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{out}}).Marshal()))
case "test.authorizeProducts2":
checkSigned(false)
cols := []xmlrpc.Value{}
for _, c := range authColumns {
cols = append(cols, c)
}
rows := []xmlrpc.Value{cols}
auths, _ := call.Params[0].(xmlrpc.Struct).Get("auths")
for _, a := range auths.([]xmlrpc.Value)[1:] {
pid := a.([]xmlrpc.Value)[1].(int64)
rows = append(rows, []xmlrpc.Value{int64(7), int64(400), pid, "sum", fmt.Sprintf("http://x/productdata/%d/1", pid), "", false, false, int64(1), true, int64(40), true, ""},
[]xmlrpc.Value{int64(7), int64(0), int64(400), "sum2", "http://x/productdata/400/1", "", false, false, int64(1), true, int64(40), true, ""})
}
return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{rows}}).Marshal()))
}
}
f.t.Errorf("unexpected request to IMVU: %s", r.URL)
return reply("")
}
type harness struct {
t *testing.T
p *Products
imvu *fakeIMVU
proxy *httptest.Server
}
func newHarness(t *testing.T) (*harness, string) {
packs := t.TempDir()
writePack(t, packs, "blue-coat", 1)
p, err := New([]string{packs}, t.TempDir(), t.Logf)
if err != nil {
t.Fatal(err)
}
f := &fakeIMVU{t: t, asked: map[string][]string{}, look: []int64{80, 1}}
srv := httptest.NewServer(&proxy.Server{Token: token, Transport: f, Interceptor: p, IMQFilter: p})
t.Cleanup(srv.Close)
return &harness{t: t, p: p, imvu: f, proxy: srv}, packs
}
// do sends a request through the proxy as the client does: signed (if signed), in absolute form.
func (h *harness) do(method, url string, body []byte, signed, withQuery bool) (int, []byte) {
h.t.Helper()
req, _ := http.NewRequest(method, url, bytes.NewReader(body))
req.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("t5de:"+token)))
if signed {
req.Header.Set("X-imvu-userid", user)
req.Header.Set("X-imvu-auth", sign(user, key, body, req.URL.RawQuery, withQuery))
}
conn, err := net.Dial("tcp", h.proxy.Listener.Addr().String())
if err != nil {
h.t.Fatal(err)
}
defer conn.Close()
req.WriteProxy(conn)
resp, err := http.ReadResponse(bufio.NewReader(conn), req)
if err != nil {
h.t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, b
}
func call(method string, params ...xmlrpc.Value) []byte {
return (&xmlrpc.Call{Method: method, Params: params}).Marshal()
}
func TestCommunityProductEndToEnd(t *testing.T) {
h, _ := newHarness(t)
local := h.p.byProductID("blue-coat").local
if local != FirstLocalID {
t.Fatalf("local ID %d", local)
}
api := "http://client-dynamic.imvu.com/api/xmlrpc/client.php"
// Logging in teaches the proxy the session key.
h.do("POST", "https://secure.imvu.com/api/xmlrpc/client.php", call("test.avatarInfoForLogin2"), false, false)
// Product info: the community product's from its parent's, with its own name; IMVU is asked for the parent.
_, body := h.do("POST", api, call("test.inventoryProductBatch", []xmlrpc.Value{int64(1), local}), true, false)
r, err := xmlrpc.ParseResponse(body)
if err != nil {
t.Fatal(err, string(body))
}
list := r.Params[0].([]xmlrpc.Value)
if len(list) != 2 {
t.Fatalf("%d entries: %s", len(list), body)
}
ours := list[1].(xmlrpc.Struct)
if id, _ := ours.Get("products_id"); id != local {
t.Fatalf("entry %v", ours)
}
if name, _ := ours.Get("products_name"); name != "Blue <Coat>" {
t.Fatalf("name %v", name)
}
if path, _ := ours.Get("cPath"); path != "106_40" {
t.Fatalf("cPath %v", path)
}
if asked := h.imvu.asked["test.inventoryProductBatch"]; len(asked) != 1 || strings.Contains(asked[0], fmt.Sprint(local)) || !strings.Contains(asked[0], "<int>500</int>") {
t.Fatalf("IMVU was asked %v", asked)
}
// Permission: our row (with the CFL's address and checksum) and IMVU's rows for the parent's chain.
auth := xmlrpc.Struct{{Name: "auths", Value: []xmlrpc.Value{[]xmlrpc.Value{"userId", "productId"}, []xmlrpc.Value{int64(7), local}}},
{Name: "use_extracted_assets_if_available", Value: true}, {Name: "max_supported_format", Value: int64(9)}}
_, body = h.do("POST", api, call("test.authorizeProducts2", auth), true, false)
r, err = xmlrpc.ParseResponse(body)
if err != nil {
t.Fatal(err)
}
rows := r.Params[0].([]xmlrpc.Value)
var cflAddress string
for _, row := range rows[1:] {
values := row.([]xmlrpc.Value)
if values[2] == local {
cflAddress = values[4].(string)
if values[1] != int64(parent) || values[3] != h.p.byProductID("blue-coat").checksum || values[6] != true {
t.Fatalf("our row %v", values)
}
}
}
if cflAddress == "" || len(rows) != 4 {
t.Fatalf("rows %v", rows)
}
// The CFL: the pack's files, with the pack's name in index.xml. IMVU is never asked.
status, data := h.do("GET", cflAddress, nil, false, false)
entries, err := cfl.Read(data)
if status != 200 || err != nil || len(entries) != 2 {
t.Fatalf("%d %v %v", status, err, entries)
}
if !bytes.Contains(entries[0].Data, []byte("<ProductName>Blue &lt;Coat&gt;</ProductName>")) {
t.Fatalf("index.xml %s", entries[0].Data)
}
if status, _ := h.do("GET", strings.Replace(cflAddress, "/1", "/2", 1), nil, false, false); status != 404 {
t.Fatalf("another revision: %d", status)
}
// Saving an outfit with it: IMVU gets the outfit without it; loading the outfit adds it back.
h.do("POST", "http://client-dynamic.imvu.com/api/base_look.php", []byte(fmt.Sprintf(`{"pids": [80, 1, %d]}`, local)), true, true)
if saved := h.imvu.asked["save look"]; len(saved) != 1 || saved[0] != `{"pids":[80,1]}` {
t.Fatalf("IMVU saved %v", saved)
}
_, body = h.do("GET", "http://client-dynamic.imvu.com/api/base_look.php", nil, true, true)
if string(body) != fmt.Sprintf(`{"result":[80,1,%d]}`, local) {
t.Fatalf("look %s", body)
}
_, body = h.do("POST", "https://secure.imvu.com/api/xmlrpc/client.php", call("test.avatarInfoForLogin2"), false, false)
if !bytes.Contains(body, []byte(fmt.Sprintf("<int>%d</int>", local))) {
t.Fatalf("login look %s", body)
}
// If the outfit IMVU has changes (saved elsewhere), it comes back without community products.
h.imvu.look = []int64{80, 2}
_, body = h.do("GET", "http://client-dynamic.imvu.com/api/base_look.php", nil, true, true)
if string(body) != `{"result":[80,2]}` {
t.Fatalf("changed look %s", body)
}
// The shop's bundle lookup is answered without IMVU.
if _, body := h.do("GET", fmt.Sprintf("http://client-dynamic.imvu.com/api/shop/product.php?pid=%d", local), nil, true, true); string(body) != `{"products":[]}` {
t.Fatalf("product.php %s", body)
}
}
func TestOutgoingChat(t *testing.T) {
h, _ := newHarness(t)
local := h.p.byProductID("blue-coat").local
msg := func(text string) []byte {
return []byte(fmt.Sprintf(`{"to": 0, "message": %q, "userId": 7, "chatId": 9}`, text))
}
out, drop := h.p.Outgoing("/chat/9", "messages", msg(fmt.Sprintf("*use 80 1 %d", local)))
if drop || !bytes.Contains(out, []byte(`"message":"*use 80 1"`)) {
t.Fatalf("%s %v", out, drop)
}
if got := h.p.wornLocal(user, []int64{1, 80}); len(got) != 1 || got[0] != local {
t.Fatalf("worn %v", got)
}
// With a different IMVU outfit (changed elsewhere), community products aren't added to it.
if got := h.p.wornLocal(user, []int64{80, 2}); len(got) != 0 {
t.Fatalf("worn with another outfit %v", got)
}
if _, drop := h.p.Outgoing("/chat/9", "messages", msg(fmt.Sprintf("*use %d", local))); !drop {
t.Fatal("a command with only community products was sent")
}
if out, _ := h.p.Outgoing("/chat/9", "messages", msg("hello")); !bytes.Equal(out, msg("hello")) {
t.Fatal("plain chat changed")
}
if out, _ := h.p.Outgoing("room_occupancy/1", "occupants", []byte("x")); string(out) != "x" {
t.Fatal("another queue changed")
}
}
func TestPackChangesAreServed(t *testing.T) {
h, packs := newHarness(t)
before := h.p.byProductID("blue-coat")
os.WriteFile(filepath.Join(packs, "blue-coat", "files", "tex.png"), []byte("a new texture, longer"), 0o644)
h.p.refresh(true)
after := h.p.byProductID("blue-coat")
if after.checksum == before.checksum || after.local != before.local {
t.Fatalf("checksum %s -> %s, local %d -> %d", before.checksum, after.checksum, before.local, after.local)
}
os.RemoveAll(filepath.Join(packs, "blue-coat"))
h.p.refresh(true)
if h.p.byProductID("blue-coat") != nil {
t.Fatal("a removed pack is still served")
}
}
+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
}
+166
View File
@@ -0,0 +1,166 @@
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]wornOutfit `json:"worn"` // IMVU user ID -> what they wear
}
// wornOutfit is an account's outfit as last saved or sent: the IMVU products IMVU got, and the community products
// taken out. The community products are added back only to that same outfit: if IMVU's outfit changed elsewhere
// (another client, the website), they'd no longer fit it.
type wornOutfit struct {
IMVU []int64 `json:"imvu"`
Products []string `json:"products"`
}
func loadState(path string) (*state, error) {
s := &state{path: path, data: stateData{IDs: map[string]int64{}, Worn: map[string]wornOutfit{}}}
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]wornOutfit{}
}
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 with the IMVU outfit look, if they were recorded with it.
func (s *state) worn(user string, look []int64) []string {
s.mu.Lock()
defer s.mu.Unlock()
w, ok := s.data.Worn[user]
if !ok || !sameSet(w.IMVU, look) {
return nil
}
return append([]string(nil), w.Products...)
}
// setWorn records the user's outfit: the IMVU products IMVU got, and the community products taken out.
func (s *state) setWorn(user string, imvu []int64, products []string) error {
w := wornOutfit{IMVU: append([]int64(nil), imvu...), Products: append([]string(nil), products...)}
sort.Slice(w.IMVU, func(i, j int) bool { return w.IMVU[i] < w.IMVU[j] })
sort.Strings(w.Products)
s.mu.Lock()
defer s.mu.Unlock()
old, had := s.data.Worn[user]
if had && sameSet(old.IMVU, w.IMVU) && equal(old.Products, w.Products) {
return nil
}
if len(w.Products) == 0 {
if !had {
return nil
}
delete(s.data.Worn, user)
} else {
s.data.Worn[user] = w
}
return s.saveLocked()
}
// sameSet reports whether a and b hold the same IDs, in any order.
func sameSet(a, b []int64) bool {
if len(a) != len(b) {
return false
}
count := map[int64]int{}
for _, v := range a {
count[v]++
}
for _, v := range b {
if count[v] == 0 {
return false
}
count[v]--
}
return true
}
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)
}
// productID is the product a local ID was given to.
func (s *state) productID(local int64) (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for id, l := range s.data.IDs {
if l == local {
return id, true
}
}
return "", false
}
+89 -21
View File
@@ -65,8 +65,11 @@ func (s *Server) serveConnect(w http.ResponseWriter, r *http.Request) {
client = io.MultiReader(bytes.NewReader(bytes.Clone(head)), conn) client = io.MultiReader(bytes.NewReader(bytes.Clone(head)), conn)
} }
up := &stream{s: s, id: id, name: "c2g"} // Frames go to the client whole, from the gateway or from the proxy answering a dropped message, so they
// never interleave.
toClient := &lockedWriter{w: conn}
down := &stream{s: s, id: id, name: "g2c"} down := &stream{s: s, id: id, name: "g2c"}
up := &stream{s: s, id: id, name: "c2g", filter: s.IMQFilter, reply: toClient}
// IMQ is a session: when either side closes, it's over, so both connections close. // IMQ is a session: when either side closes, it's over, so both connections close.
var once sync.Once var once sync.Once
closeBoth := func() { closeBoth := func() {
@@ -85,21 +88,43 @@ func (s *Server) serveConnect(w http.ResponseWriter, r *http.Request) {
go func() { go func() {
defer wg.Done() defer wg.Done()
defer closeBoth() defer closeBoth()
down.copy(conn, upstream) down.copy(toClient, upstream)
}() }()
wg.Wait() wg.Wait()
s.logf("#%d CONNECT %s closed after %s: %s, %s", id, target, since(start), up.summary(), down.summary()) s.logf("#%d CONNECT %s closed after %s: %s, %s", id, target, since(start), up.summary(), down.summary())
} }
// stream is one direction of a CONNECT, counting bytes and IMQ frames as they pass. // IMQFilter sees the client's outgoing IMQ messages and can change or drop them.
type IMQFilter interface {
// Outgoing returns the message to send instead of message, or drop to send nothing and tell the client it
// was sent.
Outgoing(queue, mount string, message []byte) (out []byte, drop bool)
}
// lockedWriter serializes writes, each a whole number of frames.
type lockedWriter struct {
mu sync.Mutex
w io.Writer
}
func (l *lockedWriter) Write(b []byte) (int, error) {
l.mu.Lock()
defer l.mu.Unlock()
return l.w.Write(b)
}
// stream is one direction of a CONNECT. It passes IMQ frames on whole, counting them (and, outgoing, filtering
// them), or bytes as they come once the stream turns out not to be IMQ.
type stream struct { type stream struct {
s *Server s *Server
id int64 id int64
name string name string
bytes int64 bytes int64
frames int frames int
other bool // not IMQ framing other bool // not IMQ framing
reader imq.Reader pending []byte
filter IMQFilter // outgoing only
reply io.Writer // outgoing only: to the client
} }
func (st *stream) copy(dst io.Writer, src io.Reader) { func (st *stream) copy(dst io.Writer, src io.Reader) {
@@ -107,8 +132,8 @@ func (st *stream) copy(dst io.Writer, src io.Reader) {
for { for {
n, err := src.Read(buf) n, err := src.Read(buf)
if n > 0 { if n > 0 {
st.observe(buf[:n]) st.bytes += int64(n)
if _, werr := dst.Write(buf[:n]); werr != nil { if werr := st.pass(dst, buf[:n]); werr != nil {
return return
} }
} }
@@ -118,22 +143,65 @@ func (st *stream) copy(dst io.Writer, src io.Reader) {
} }
} }
func (st *stream) observe(data []byte) { // pass writes on the frames data completes.
st.bytes += int64(len(data)) func (st *stream) pass(dst io.Writer, data []byte) error {
if st.other { if st.other {
return _, err := dst.Write(data)
return err
} }
frames, err := st.reader.Write(data) st.pending = append(st.pending, data...)
st.frames += len(frames) var out []byte
if st.s.Trace { for {
for _, f := range frames { f, n, err := imq.Next(st.pending)
if err != nil {
st.other = true
st.s.logf("#%d %s isn't IMQ framing; passing it through", st.id, st.name)
out = append(out, st.pending...)
st.pending = nil
break
}
if n == 0 {
break
}
frame := st.pending[:n]
st.pending = st.pending[n:]
st.frames++
if st.s.Trace {
st.s.logf("#%d %s %s %dB", st.id, st.name, imq.TypeName(f.Type), f.Size) st.s.logf("#%d %s %s %dB", st.id, st.name, imq.TypeName(f.Type), f.Size)
} }
out = append(out, st.filtered(f, frame)...)
} }
if len(st.pending) == 0 {
st.pending = nil
}
if len(out) == 0 {
return nil
}
_, err := dst.Write(out)
return err
}
// filtered is the frame to send on: as is, changed by the filter, or none (answered here).
func (st *stream) filtered(f imq.Frame, frame []byte) []byte {
if st.filter == nil || f.Type != imq.C2gSendMessage {
return frame
}
m, err := imq.ParseSendMessage(f.Data)
if err != nil { if err != nil {
st.other = true return frame
st.s.logf("#%d %s isn't IMQ framing; passing it through", st.id, st.name)
} }
out, drop := st.filter.Outgoing(m.Queue, m.Mount, m.Message)
if drop {
if st.reply != nil {
st.reply.Write(imq.EncodeFrame(imq.G2cResult, imq.Result(m.OpID, 0)))
}
return nil
}
if bytes.Equal(out, m.Message) {
return frame
}
m.Message = out
return imq.EncodeFrame(imq.C2gSendMessage, m.Marshal())
} }
func (st *stream) summary() string { func (st *stream) summary() string {
+113
View File
@@ -14,6 +14,7 @@ package proxy
import ( import (
"bytes" "bytes"
"compress/gzip"
"context" "context"
"crypto/subtle" "crypto/subtle"
"crypto/tls" "crypto/tls"
@@ -24,6 +25,7 @@ import (
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"strings" "strings"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -45,6 +47,10 @@ type Server struct {
// TLSConfig is the base TLS configuration of CONNECT's upstream connections (tests set RootCAs); nil is the // TLSConfig is the base TLS configuration of CONNECT's upstream connections (tests set RootCAs); nil is the
// default, with the system's roots. // default, with the system's roots.
TLSConfig *tls.Config TLSConfig *tls.Config
// Interceptor, if set, sees every HTTP request (with a body the proxy could hold in memory).
Interceptor Interceptor
// IMQFilter, if set, sees the client's outgoing IMQ messages (on CONNECT streams) and can change or drop them.
IMQFilter IMQFilter
// Dial makes CONNECT's upstream connections (tests point it at local servers); nil is a net.Dialer. // Dial makes CONNECT's upstream connections (tests point it at local servers); nil is a net.Dialer.
Dial func(ctx context.Context, network, addr string) (net.Conn, error) Dial func(ctx context.Context, network, addr string) (net.Conn, error)
@@ -169,6 +175,29 @@ func (s *Server) forward(w http.ResponseWriter, r *http.Request) {
http.Error(w, "t5de-proxy: "+err.Error(), http.StatusBadRequest) http.Error(w, "t5de-proxy: "+err.Error(), http.StatusBadRequest)
return return
} }
var modify func(*http.Response, []byte) ([]byte, error)
if s.Interceptor != nil && replayable {
x := &Exchange{URL: r.URL, Request: out}
if out.GetBody != nil {
body, _ := out.GetBody()
x.Body, _ = io.ReadAll(body)
}
original := x.Body
if err := s.Interceptor.Intercept(x); err != nil {
s.logf("#%d %s %s: %v", id, r.Method, s.logURL(r.URL), err)
w.Header().Set(ErrorHeader, err.Error())
http.Error(w, "t5de-proxy: "+err.Error(), http.StatusBadGateway)
return
}
if x.Response != nil {
s.writeLocal(w, id, r, x.Response, start)
return
}
if !bytes.Equal(x.Body, original) {
setBody(out, x.Body)
}
modify = x.ModifyResponse
}
resp, err := s.transport().RoundTrip(out) resp, err := s.transport().RoundTrip(out)
if err != nil && upgraded && replayable && r.Context().Err() == nil { if err != nil && upgraded && replayable && r.Context().Err() == nil {
@@ -191,6 +220,11 @@ func (s *Server) forward(w http.ResponseWriter, r *http.Request) {
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
if modify != nil {
if err := modifyResponse(resp, modify); err != nil {
s.logf("#%d %s %s: changing the response: %v", id, r.Method, s.logURL(r.URL), err)
}
}
removeHopHeaders(resp.Header) removeHopHeaders(resp.Header)
for name, values := range resp.Header { for name, values := range resp.Header {
@@ -208,6 +242,85 @@ func (s *Server) forward(w http.ResponseWriter, r *http.Request) {
s.logf("#%d %s %s %d %dB %s%s", id, r.Method, s.logURL(r.URL), resp.StatusCode, n, since(start), note) s.logf("#%d %s %s %d %dB %s%s", id, r.Method, s.logURL(r.URL), resp.StatusCode, n, since(start), note)
} }
// Interceptor can answer requests itself, change them before they're sent, and change their responses. The
// t5de-proxy's community products are served this way.
type Interceptor interface {
Intercept(x *Exchange) error
}
// Exchange is a request on its way, as an Interceptor sees it.
type Exchange struct {
// URL is the URL the client asked for (before any upgrade to HTTPS).
URL *url.URL
// Request is the request about to be sent; its body is Body.
Request *http.Request
// Body is the request's body; an Interceptor may replace it.
Body []byte
// Response, if an Interceptor sets it, answers the request: nothing is sent.
Response *http.Response
// ModifyResponse, if set, changes the server's response body (decompressed) before the client gets it.
ModifyResponse func(resp *http.Response, body []byte) ([]byte, error)
}
// LocalResponse is a response made by the proxy.
func LocalResponse(status int, contentType string, body []byte) *http.Response {
h := http.Header{}
h.Set("Content-Type", contentType)
h.Set("Content-Length", strconv.Itoa(len(body)))
return &http.Response{StatusCode: status, Header: h, ContentLength: int64(len(body)), Body: io.NopCloser(bytes.NewReader(body))}
}
func (s *Server) writeLocal(w http.ResponseWriter, id int64, r *http.Request, resp *http.Response, start time.Time) {
defer resp.Body.Close()
for name, values := range resp.Header {
w.Header()[name] = values
}
w.WriteHeader(resp.StatusCode)
n, _ := io.Copy(w, resp.Body)
s.logf("#%d %s %s %d %dB %s (answered by the proxy)", id, r.Method, s.logURL(r.URL), resp.StatusCode, n, since(start))
}
func setBody(out *http.Request, body []byte) {
out.ContentLength = int64(len(body))
out.Header.Set("Content-Length", strconv.Itoa(len(body)))
out.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil }
out.Body, _ = out.GetBody()
if len(body) == 0 {
out.Body, out.GetBody = nil, nil
}
}
// modifyResponse replaces resp's body with modify's version of it, uncompressed.
func modifyResponse(resp *http.Response, modify func(*http.Response, []byte) ([]byte, error)) error {
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBuffered))
resp.Body.Close()
if err != nil {
resp.Body = io.NopCloser(bytes.NewReader(raw))
return err
}
body := raw
if strings.EqualFold(resp.Header.Get("Content-Encoding"), "gzip") {
zr, err := gzip.NewReader(bytes.NewReader(raw))
if err == nil {
body, err = io.ReadAll(io.LimitReader(zr, maxBuffered))
}
if err != nil {
resp.Body = io.NopCloser(bytes.NewReader(raw))
return err
}
}
changed, err := modify(resp, body)
if err != nil {
changed = body
}
resp.Header.Del("Content-Encoding")
resp.Header.Del("Content-Md5")
resp.Header.Set("Content-Length", strconv.Itoa(len(changed)))
resp.ContentLength = int64(len(changed))
resp.Body = io.NopCloser(bytes.NewReader(changed))
return err
}
// maxBuffered bounds the request bodies the proxy holds in memory; larger ones (uploads) are streamed. // maxBuffered bounds the request bodies the proxy holds in memory; larger ones (uploads) are streamed.
const maxBuffered = 32 << 20 const maxBuffered = 32 << 20
+134
View File
@@ -3,6 +3,7 @@ package proxy
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"compress/gzip"
"context" "context"
"crypto/tls" "crypto/tls"
"encoding/base64" "encoding/base64"
@@ -18,6 +19,8 @@ import (
"sync" "sync"
"testing" "testing"
"time" "time"
"git.t5de.dev/t5de/t5de-proxy/internal/imq"
) )
const token = "secret" const token = "secret"
@@ -340,3 +343,134 @@ func TestFallsBackToHTTPWithTheBody(t *testing.T) {
t.Fatalf("%d %q", resp.StatusCode, got) t.Fatalf("%d %q", resp.StatusCode, got)
} }
} }
// suffixFilter appends "!" to messages on mount "messages" and drops those saying "drop".
type suffixFilter struct{}
func (suffixFilter) Outgoing(queue, mount string, message []byte) ([]byte, bool) {
if string(message) == "drop" {
return nil, true
}
if mount == "messages" {
return append(bytes.Clone(message), '!'), false
}
return message, false
}
func TestConnectFiltersOutgoingMessages(t *testing.T) {
gateway := httptest.NewUnstartedServer(nil)
gateway.StartTLS()
gateway.Close()
ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: gateway.TLS.Certificates})
if err != nil {
t.Fatal(err)
}
defer ln.Close()
received := make(chan []byte, 1)
go func() {
c, err := ln.Accept()
if err != nil {
return
}
defer c.Close()
var got []byte
buf := make([]byte, 4096)
c.SetReadDeadline(time.Now().Add(2 * time.Second))
for {
n, err := c.Read(buf)
got = append(got, buf[:n]...)
if err != nil {
break
}
}
received <- got
}()
roots := gateway.Client().Transport.(*http.Transport).TLSClientConfig.RootCAs
p, logs := newProxy(t, &Server{TLSConfig: &tls.Config{RootCAs: roots}, IMQFilter: suffixFilter{},
Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
return net.Dial(network, ln.Addr().String())
}})
conn, err := net.Dial("tcp", p.Listener.Addr().String())
if err != nil {
t.Fatal(err)
}
defer conn.Close()
send := func(op uint64, mount, msg string) []byte {
return imq.EncodeFrame(imq.C2gSendMessage, imq.SendMessage{OpID: op, Queue: "/chat/1", Mount: mount, Message: []byte(msg)}.Marshal())
}
// Sent in pieces, to check frames are put back together.
stream := append(append(send(1, "messages", "hi"), send(2, "messages", "drop")...), send(3, "control", "x")...)
fmt.Fprintf(conn, "CONNECT example.com:443 HTTP/1.1\r\nProxy-authorization: %s\r\n\r\n", auth)
r := bufio.NewReader(conn)
for line, _ := r.ReadString('\n'); line != "\r\n"; line, _ = r.ReadString('\n') {
if line == "" {
t.Fatalf("no response; log %s", logs)
}
}
for i := 0; i < len(stream); i += 5 {
conn.Write(stream[i:min(i+5, len(stream))])
time.Sleep(time.Millisecond)
}
// The dropped message is answered by the proxy.
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
var fromProxy []byte
buf := make([]byte, 64)
for len(fromProxy) < len(imq.EncodeFrame(imq.G2cResult, imq.Result(2, 0))) {
n, err := r.Read(buf)
if err != nil {
t.Fatalf("%v; log %s", err, logs)
}
fromProxy = append(fromProxy, buf[:n]...)
}
if !bytes.Equal(fromProxy, imq.EncodeFrame(imq.G2cResult, imq.Result(2, 0))) {
t.Fatalf("client got %x", fromProxy)
}
conn.Close()
got := <-received
want := append(send(1, "messages", "hi!"), send(3, "control", "x")...)
if !bytes.Equal(got, want) {
t.Fatalf("gateway got %q, want %q", got, want)
}
}
// testInterceptor answers /local itself, uppercases request bodies to /change, and appends to their responses.
type testInterceptor struct{}
func (testInterceptor) Intercept(x *Exchange) error {
switch x.URL.Path {
case "/local":
x.Response = LocalResponse(200, "text/plain", []byte("from the proxy"))
case "/change":
x.Body = bytes.ToUpper(x.Body)
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
return append(body, " (changed)"...), nil
}
}
return nil
}
func TestInterceptor(t *testing.T) {
var gotBody string
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/local" {
t.Error("a request answered by the proxy reached the server")
}
b, _ := io.ReadAll(r.Body)
gotBody = string(b)
// Compressed, as IMVU's servers answer: the interceptor sees it decompressed.
w.Header().Set("Content-Encoding", "gzip")
zw := gzip.NewWriter(w)
io.WriteString(zw, "echo "+gotBody)
zw.Close()
}))
defer origin.Close()
p, _ := newProxy(t, &Server{Interceptor: testInterceptor{}})
resp := send(t, p, "GET "+origin.URL+"/local HTTP/1.1\r\nHost: x\r\nProxy-Authorization: "+auth+"\r\n\r\n")
if b := body(t, resp); b != "from the proxy" {
t.Fatalf("local: %q", b)
}
resp = send(t, p, "POST "+origin.URL+"/change HTTP/1.1\r\nHost: x\r\nProxy-Authorization: "+auth+"\r\nContent-Length: 5\r\n\r\nhello")
if b := body(t, resp); b != "echo HELLO (changed)" || gotBody != "HELLO" || resp.Header.Get("Content-Encoding") != "" {
t.Fatalf("changed: %q, server got %q, %v", b, gotBody, resp.Header)
}
}
+525
View File
@@ -0,0 +1,525 @@
// Package xmlrpc parses and writes XML-RPC calls and responses, keeping
// value types and struct member order exactly.
package xmlrpc
import (
"bytes"
"encoding/base64"
"encoding/xml"
"errors"
"fmt"
"io"
"strconv"
"strings"
)
// MaxSize is the largest document accepted.
const MaxSize = 64 << 20
// MaxDepth is the deepest nesting of arrays and structs accepted.
const MaxDepth = 64
// Value is one of: int64, bool, string, float64, []byte (base64), DateTime,
// []Value (array), Struct, or nil (<nil/>).
type Value = any
// DateTime is dateTime.iso8601 text, kept as is.
type DateTime string
// Member is a struct member.
type Member struct {
Name string
Value Value
}
// Struct is an XML-RPC struct, in document order.
type Struct []Member
// Get returns the value of the first member with the name.
func (s Struct) Get(name string) (Value, bool) {
for _, m := range s {
if m.Name == name {
return m.Value, true
}
}
return nil, false
}
// Call is a methodCall.
type Call struct {
Method string
Params []Value
}
// Response is a methodResponse: Params, or Fault if it's set.
type Response struct {
Params []Value
Fault *Fault
}
// Fault is a fault response's code and message.
type Fault struct {
Code int64
String string
}
func (f *Fault) Error() string { return fmt.Sprintf("xmlrpc fault %d: %s", f.Code, f.String) }
type parser struct{ d *xml.Decoder }
func newParser(data []byte) (*parser, error) {
if len(data) > MaxSize {
return nil, errors.New("xmlrpc: document too large")
}
d := xml.NewDecoder(bytes.NewReader(data))
d.Strict = true
d.CharsetReader = func(label string, r io.Reader) (io.Reader, error) {
switch strings.ToLower(label) {
case "utf-8", "utf8", "us-ascii", "ascii":
return r, nil
}
return nil, fmt.Errorf("xmlrpc: unsupported encoding %q", label)
}
return &parser{d}, nil
}
func (p *parser) token() (xml.Token, error) {
t, err := p.d.Token()
if err == io.EOF {
err = io.ErrUnexpectedEOF
}
return t, err
}
// next returns the next start or end element, skipping whitespace,
// comments and processing instructions.
func (p *parser) next() (xml.Token, error) {
for {
t, err := p.token()
if err != nil {
return nil, err
}
switch t := t.(type) {
case xml.StartElement, xml.EndElement:
return t, nil
case xml.CharData:
if len(bytes.TrimSpace(t)) != 0 {
return nil, fmt.Errorf("xmlrpc: unexpected text %q", string(t))
}
}
}
}
func (p *parser) start(name string) error {
t, err := p.next()
if err != nil {
return err
}
if s, ok := t.(xml.StartElement); !ok || s.Name.Local != name {
return fmt.Errorf("xmlrpc: expected <%s>", name)
}
return nil
}
func (p *parser) end(name string) error {
t, err := p.next()
if err != nil {
return err
}
if e, ok := t.(xml.EndElement); !ok || e.Name.Local != name {
return fmt.Errorf("xmlrpc: expected </%s>", name)
}
return nil
}
// text reads character data up to the current element's end.
func (p *parser) text() (string, error) {
var b strings.Builder
for {
t, err := p.token()
if err != nil {
return "", err
}
switch t := t.(type) {
case xml.CharData:
b.Write(t)
case xml.EndElement:
return b.String(), nil
case xml.StartElement:
return "", fmt.Errorf("xmlrpc: unexpected <%s>", t.Name.Local)
}
}
}
// eof checks that only whitespace follows the root element.
func (p *parser) eof() error {
for {
t, err := p.d.Token()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
switch t := t.(type) {
case xml.StartElement, xml.EndElement:
return errors.New("xmlrpc: content after document")
case xml.CharData:
if len(bytes.TrimSpace(t)) != 0 {
return errors.New("xmlrpc: content after document")
}
}
}
}
// value parses a value's content; its <value> start was read.
// Bare text is a string, whitespace included.
func (p *parser) value(depth int) (Value, error) {
var text strings.Builder
for {
t, err := p.token()
if err != nil {
return nil, err
}
switch t := t.(type) {
case xml.CharData:
text.Write(t)
case xml.EndElement:
return text.String(), nil
case xml.StartElement:
if strings.TrimSpace(text.String()) != "" {
return nil, errors.New("xmlrpc: text mixed with elements in <value>")
}
v, err := p.typed(t.Name.Local, depth)
if err != nil {
return nil, err
}
return v, p.end("value")
}
}
}
func (p *parser) typed(name string, depth int) (Value, error) {
switch name {
case "array", "struct":
if depth >= MaxDepth {
return nil, errors.New("xmlrpc: nested too deeply")
}
if name == "array" {
return p.array(depth + 1)
}
return p.strct(depth + 1)
}
s, err := p.text()
if err != nil {
return nil, err
}
switch name {
case "nil":
return nil, nil
case "string":
return s, nil
case "int", "i4", "i8":
n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
if err != nil {
return nil, fmt.Errorf("xmlrpc: bad <%s> %q", name, s)
}
return n, nil
case "boolean":
switch strings.TrimSpace(s) {
case "0":
return false, nil
case "1":
return true, nil
}
return nil, fmt.Errorf("xmlrpc: bad <boolean> %q", s)
case "double":
f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
if err != nil {
return nil, fmt.Errorf("xmlrpc: bad <double> %q", s)
}
return f, nil
case "dateTime.iso8601":
return DateTime(s), nil
case "base64":
b, err := base64.StdEncoding.DecodeString(strings.Join(strings.Fields(s), ""))
if err != nil {
return nil, fmt.Errorf("xmlrpc: bad <base64>: %v", err)
}
return b, nil
}
return nil, fmt.Errorf("xmlrpc: unknown type <%s>", name)
}
func (p *parser) array(depth int) (Value, error) {
if err := p.start("data"); err != nil {
return nil, err
}
a := []Value{}
for {
t, err := p.next()
if err != nil {
return nil, err
}
switch t := t.(type) {
case xml.EndElement:
return a, p.end("array")
case xml.StartElement:
if t.Name.Local != "value" {
return nil, fmt.Errorf("xmlrpc: unexpected <%s> in <data>", t.Name.Local)
}
v, err := p.value(depth)
if err != nil {
return nil, err
}
a = append(a, v)
}
}
}
func (p *parser) strct(depth int) (Value, error) {
s := Struct{}
for {
t, err := p.next()
if err != nil {
return nil, err
}
switch t := t.(type) {
case xml.EndElement:
return s, nil
case xml.StartElement:
if t.Name.Local != "member" {
return nil, fmt.Errorf("xmlrpc: unexpected <%s> in <struct>", t.Name.Local)
}
if err := p.start("name"); err != nil {
return nil, err
}
name, err := p.text()
if err != nil {
return nil, err
}
if err := p.start("value"); err != nil {
return nil, err
}
v, err := p.value(depth)
if err != nil {
return nil, err
}
if err := p.end("member"); err != nil {
return nil, err
}
s = append(s, Member{name, v})
}
}
}
// params parses <param> elements up to </params>.
func (p *parser) params() ([]Value, error) {
ps := []Value{}
for {
t, err := p.next()
if err != nil {
return nil, err
}
switch t := t.(type) {
case xml.EndElement:
return ps, nil
case xml.StartElement:
if t.Name.Local != "param" {
return nil, fmt.Errorf("xmlrpc: unexpected <%s> in <params>", t.Name.Local)
}
if err := p.start("value"); err != nil {
return nil, err
}
v, err := p.value(0)
if err != nil {
return nil, err
}
if err := p.end("param"); err != nil {
return nil, err
}
ps = append(ps, v)
}
}
}
// ParseCall parses a methodCall document.
func ParseCall(data []byte) (*Call, error) {
p, err := newParser(data)
if err != nil {
return nil, err
}
if err := p.start("methodCall"); err != nil {
return nil, err
}
if err := p.start("methodName"); err != nil {
return nil, err
}
method, err := p.text()
if err != nil {
return nil, err
}
c := &Call{Method: strings.TrimSpace(method), Params: []Value{}}
t, err := p.next()
if err != nil {
return nil, err
}
if s, ok := t.(xml.StartElement); ok && s.Name.Local == "params" {
if c.Params, err = p.params(); err != nil {
return nil, err
}
if err := p.end("methodCall"); err != nil {
return nil, err
}
} else if e, ok := t.(xml.EndElement); !ok || e.Name.Local != "methodCall" {
return nil, errors.New("xmlrpc: expected <params>")
}
return c, p.eof()
}
// ParseResponse parses a methodResponse document.
func ParseResponse(data []byte) (*Response, error) {
p, err := newParser(data)
if err != nil {
return nil, err
}
if err := p.start("methodResponse"); err != nil {
return nil, err
}
t, err := p.next()
if err != nil {
return nil, err
}
s, _ := t.(xml.StartElement)
r := &Response{}
switch s.Name.Local {
case "params":
if r.Params, err = p.params(); err != nil {
return nil, err
}
case "fault":
if r.Fault, err = p.fault(); err != nil {
return nil, err
}
default:
return nil, errors.New("xmlrpc: expected <params> or <fault>")
}
if err := p.end("methodResponse"); err != nil {
return nil, err
}
return r, p.eof()
}
func (p *parser) fault() (*Fault, error) {
if err := p.start("value"); err != nil {
return nil, err
}
v, err := p.value(0)
if err != nil {
return nil, err
}
if err := p.end("fault"); err != nil {
return nil, err
}
st, _ := v.(Struct)
code, _ := st.Get("faultCode")
msg, _ := st.Get("faultString")
f := &Fault{}
var ok1, ok2 bool
f.Code, ok1 = code.(int64)
f.String, ok2 = msg.(string)
if !ok1 || !ok2 {
return nil, errors.New("xmlrpc: bad fault")
}
return f, nil
}
// Marshal writes the call. It panics on a value of an unsupported type.
func (c *Call) Marshal() []byte {
var b bytes.Buffer
b.WriteString("<?xml version='1.0'?>\n<methodCall>\n<methodName>")
xml.EscapeText(&b, []byte(c.Method))
b.WriteString("</methodName>\n")
writeParams(&b, c.Params)
b.WriteString("</methodCall>\n")
return b.Bytes()
}
// Marshal writes the response. It panics on a value of an unsupported type.
func (r *Response) Marshal() []byte {
var b bytes.Buffer
b.WriteString("<?xml version='1.0'?>\n<methodResponse>\n")
if r.Fault != nil {
b.WriteString("<fault>\n")
writeValue(&b, Struct{{"faultCode", r.Fault.Code}, {"faultString", r.Fault.String}})
b.WriteString("</fault>\n")
} else {
writeParams(&b, r.Params)
}
b.WriteString("</methodResponse>\n")
return b.Bytes()
}
func writeParams(b *bytes.Buffer, ps []Value) {
b.WriteString("<params>\n")
for _, v := range ps {
b.WriteString("<param>\n")
writeValue(b, v)
b.WriteString("</param>\n")
}
b.WriteString("</params>\n")
}
// writeText escapes s; xml.EscapeText also escapes quotes and \t\n\r as
// character references, so whitespace survives exactly.
func writeText(b *bytes.Buffer, s string) { xml.EscapeText(b, []byte(s)) }
func writeValue(b *bytes.Buffer, v Value) {
b.WriteString("<value>")
switch v := v.(type) {
case nil:
b.WriteString("<nil/>")
case int64:
fmt.Fprintf(b, "<int>%d</int>", v)
case int:
fmt.Fprintf(b, "<int>%d</int>", v)
case bool:
if v {
b.WriteString("<boolean>1</boolean>")
} else {
b.WriteString("<boolean>0</boolean>")
}
case string:
b.WriteString("<string>")
writeText(b, v)
b.WriteString("</string>")
case float64:
// Shortest round-trip form; Python's float() parses it, NaN and Inf too.
fmt.Fprintf(b, "<double>%s</double>", strconv.FormatFloat(v, 'g', -1, 64))
case []byte:
fmt.Fprintf(b, "<base64>%s</base64>", base64.StdEncoding.EncodeToString(v))
case DateTime:
b.WriteString("<dateTime.iso8601>")
writeText(b, string(v))
b.WriteString("</dateTime.iso8601>")
case []Value:
b.WriteString("<array><data>\n")
for _, e := range v {
writeValue(b, e)
}
b.WriteString("</data></array>")
case Struct:
b.WriteString("<struct>\n")
for _, m := range v {
b.WriteString("<member>\n<name>")
writeText(b, m.Name)
b.WriteString("</name>\n")
writeValue(b, m.Value)
b.WriteString("</member>\n")
}
b.WriteString("</struct>")
default:
panic(fmt.Sprintf("xmlrpc: unsupported type %T", v))
}
b.WriteString("</value>\n")
}
+303
View File
@@ -0,0 +1,303 @@
package xmlrpc
import (
"bytes"
"reflect"
"strings"
"testing"
)
// Python 2.7 xmlrpclib.dumps(..., methodname=...) output, synthetic values.
const authorizeCall = `<?xml version='1.0'?>
<methodCall>
<methodName>test.authorizeProducts2</methodName>
<params>
<param>
<value><struct>
<member>
<name>auths</name>
<value><array><data>
<value><array><data>
<value><string>userId</string></value>
<value><string>productId</string></value>
</data></array></value>
<value><array><data>
<value><int>123</int></value>
<value><int>456</int></value>
</data></array></value>
</data></array></value>
</member>
<member>
<name>use_extracted_assets_if_available</name>
<value><boolean>1</boolean></value>
</member>
<member>
<name>max_supported_format</name>
<value><int>9</int></value>
</member>
</struct></value>
</param>
</params>
</methodCall>
`
// PHP-style response: declaration with encoding, indentation, i4, bare text.
const phpResponse = `<?xml version="1.0" encoding="UTF-8"?>
<methodResponse>
<params>
<param>
<value>
<array>
<data>
<value>
<array>
<data>
<value><i4>1001</i4></value>
<value><string> Spaced &amp; &lt;name&gt; </string></value>
<value><boolean>0</boolean></value>
</data>
</array>
</value>
<value>
<array>
<data>
<value><int>-7</int></value>
<value>bare text</value>
<value><boolean>1</boolean></value>
<value><i8>9007199254740993</i8></value>
<value><double>0.1</double></value>
<value><dateTime.iso8601>20261029T12:34:56</dateTime.iso8601></value>
<value><base64>aGVs
bG8=</base64></value>
<value><nil/></value>
<value></value>
<value/>
<value><string/></value>
<value><struct></struct></value>
<value><array><data></data></array></value>
</data>
</array>
</value>
</data>
</array>
</value>
</param>
</params>
</methodResponse>
`
const faultResponse = `<?xml version="1.0" encoding="UTF-8"?>
<methodResponse>
<fault>
<value>
<struct>
<member><name>faultCode</name><value><int>4</int></value></member>
<member><name>faultString</name><value><string>Too many parameters.</string></value></member>
</struct>
</value>
</fault>
</methodResponse>
`
const unicodeCall = "<?xml version='1.0'?>\n<methodCall><methodName>test.echo</methodName><params>" +
"<param><value><string>café 日本 \U0001F600 &#x263A;</string></value></param>" +
"</params></methodCall>"
func TestParseCall(t *testing.T) {
c, err := ParseCall([]byte(authorizeCall))
if err != nil {
t.Fatal(err)
}
want := &Call{Method: "test.authorizeProducts2", Params: []Value{Struct{
{"auths", []Value{[]Value{"userId", "productId"}, []Value{int64(123), int64(456)}}},
{"use_extracted_assets_if_available", true},
{"max_supported_format", int64(9)},
}}}
if !reflect.DeepEqual(c, want) {
t.Fatalf("got %#v", c)
}
roundTripCall(t, c)
}
func TestParseResponse(t *testing.T) {
r, err := ParseResponse([]byte(phpResponse))
if err != nil {
t.Fatal(err)
}
want := &Response{Params: []Value{[]Value{
[]Value{int64(1001), " Spaced & <name> ", false},
[]Value{int64(-7), "bare text", true, int64(9007199254740993), 0.1,
DateTime("20261029T12:34:56"), []byte("hello"), nil, "", "", "", Struct{}, []Value{}},
}}}
if !reflect.DeepEqual(r, want) {
t.Fatalf("got %#v", r)
}
roundTripResponse(t, r)
}
func TestFault(t *testing.T) {
r, err := ParseResponse([]byte(faultResponse))
if err != nil {
t.Fatal(err)
}
if r.Fault == nil || *r.Fault != (Fault{4, "Too many parameters."}) || r.Params != nil {
t.Fatalf("got %#v", r)
}
out := r.Marshal()
if !bytes.Contains(out, []byte("<methodResponse>\n<fault>")) {
t.Fatalf("marshal: %s", out)
}
roundTripResponse(t, r)
}
func TestUnicode(t *testing.T) {
c, err := ParseCall([]byte(unicodeCall))
if err != nil {
t.Fatal(err)
}
if s := c.Params[0]; s != "café 日本 \U0001F600 ☺" {
t.Fatalf("got %q", s)
}
roundTripCall(t, c)
}
func TestWhitespaceInString(t *testing.T) {
r := &Response{Params: []Value{" a\tb\r\nc ", Struct{{"k\n", "\r"}}, 1.5e300, -0.0}}
roundTripResponse(t, r)
}
func TestMarshalFormat(t *testing.T) {
c := &Call{Method: "m", Params: []Value{int64(1), true, "a<&>", nil, 2.5}}
want := "<?xml version='1.0'?>\n<methodCall>\n<methodName>m</methodName>\n<params>\n" +
"<param>\n<value><int>1</int></value>\n</param>\n" +
"<param>\n<value><boolean>1</boolean></value>\n</param>\n" +
"<param>\n<value><string>a&lt;&amp;&gt;</string></value>\n</param>\n" +
"<param>\n<value><nil/></value>\n</param>\n" +
"<param>\n<value><double>2.5</double></value>\n</param>\n" +
"</params>\n</methodCall>\n"
if got := string(c.Marshal()); got != want {
t.Fatalf("got\n%s", got)
}
}
func TestCallWithoutParams(t *testing.T) {
c, err := ParseCall([]byte("<methodCall><methodName>x</methodName></methodCall>"))
if err != nil || c.Method != "x" || len(c.Params) != 0 {
t.Fatal(c, err)
}
}
func TestErrors(t *testing.T) {
bad := []string{
"",
"<methodCall>",
"<methodResponse><params><param><value><int>x</int></value></param></params></methodResponse>",
"<methodResponse><params><param><value><boolean>2</boolean></value></param></params></methodResponse>",
"<methodResponse><params><param><value><double>z</double></value></param></params></methodResponse>",
"<methodResponse><params><param><value><base64>!!</base64></value></param></params></methodResponse>",
"<methodResponse><params><param><value><foo/></value></param></params></methodResponse>",
"<methodResponse><params><param><value>x<int>1</int></value></param></params></methodResponse>",
"<methodResponse><params><param><value><int>1</int></value></param></params></methodResponse><x/>",
"<methodResponse><fault><value><int>1</int></value></fault></methodResponse>",
"<methodResponse><params><param><value><string>a</value></param></params></methodResponse>",
"<methodResponse></methodResponse>",
"<methodCall><methodName>x</methodName><params>junk</params></methodCall>",
`<?xml version="1.0" encoding="ISO-8859-1"?><methodCall><methodName>x</methodName></methodCall>`,
}
for _, s := range bad {
_, err1 := ParseResponse([]byte(s))
_, err2 := ParseCall([]byte(s))
if err1 == nil && err2 == nil {
t.Errorf("accepted %q", s)
}
}
}
func nested(n int) string {
return "<methodResponse><params><param><value>" +
strings.Repeat("<array><data><value>", n) + "<int>1</int>" +
strings.Repeat("</value></data></array>", n) +
"</value></param></params></methodResponse>"
}
func TestDepth(t *testing.T) {
if _, err := ParseResponse([]byte(nested(MaxDepth))); err != nil {
t.Fatal(err)
}
if _, err := ParseResponse([]byte(nested(MaxDepth + 1))); err == nil {
t.Fatal("accepted too deep")
}
}
func TestSize(t *testing.T) {
big := make([]byte, MaxSize+1)
if _, err := ParseCall(big); err == nil {
t.Fatal("accepted too large")
}
}
func roundTripCall(t *testing.T, c *Call) {
t.Helper()
c2, err := ParseCall(c.Marshal())
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(c, c2) {
t.Fatalf("round trip:\n%#v\n%#v", c, c2)
}
}
func roundTripResponse(t *testing.T, r *Response) {
t.Helper()
r2, err := ParseResponse(r.Marshal())
if err != nil {
t.Fatal(err)
}
if r.Params == nil && r.Fault == nil {
r.Params = []Value{}
}
if !reflect.DeepEqual(r, r2) {
t.Fatalf("round trip:\n%#v\n%#v", r, r2)
}
}
// Fuzzed documents must not panic, and whatever parses must re-marshal stably
// (bytes, not values, so NaN compares equal).
func FuzzParseCall(f *testing.F) {
f.Add([]byte(authorizeCall))
f.Add([]byte(unicodeCall))
f.Fuzz(func(t *testing.T, data []byte) {
c, err := ParseCall(data)
if err != nil {
return
}
out := c.Marshal()
c2, err := ParseCall(out)
if err != nil {
t.Fatalf("reparse: %v\n%s", err, out)
}
if !bytes.Equal(out, c2.Marshal()) {
t.Fatalf("unstable:\n%s", out)
}
})
}
func FuzzParseResponse(f *testing.F) {
f.Add([]byte(phpResponse))
f.Add([]byte(faultResponse))
f.Add([]byte(nested(3)))
f.Fuzz(func(t *testing.T, data []byte) {
r, err := ParseResponse(data)
if err != nil {
return
}
out := r.Marshal()
r2, err := ParseResponse(out)
if err != nil {
t.Fatalf("reparse: %v\n%s", err, out)
}
if !bytes.Equal(out, r2.Marshal()) {
t.Fatalf("unstable:\n%s", out)
}
})
}