Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92103098f8 | ||
|
|
0e5ecf54ae | ||
|
|
887e8a2364 | ||
|
|
b2eed27b99 | ||
|
|
0c4f6caec1 | ||
|
|
bee95533c0 | ||
|
|
2190230f81 |
No files matched your search
@@ -12,6 +12,9 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
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:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
|
||||
@@ -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
|
||||
(`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`.
|
||||
- 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: ...`.
|
||||
@@ -12,6 +12,8 @@ connects to IMVU through it:
|
||||
gateway over TLS.
|
||||
- **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.
|
||||
|
||||
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`
|
||||
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
|
||||
|
||||
go test ./...
|
||||
|
||||
+38
-3
@@ -18,10 +18,13 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.t5de.dev/t5de/t5de-proxy/internal/buildinfo"
|
||||
"git.t5de.dev/t5de/t5de-proxy/internal/parent"
|
||||
"git.t5de.dev/t5de/t5de-proxy/internal/products"
|
||||
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||
)
|
||||
|
||||
@@ -31,6 +34,9 @@ func main() {
|
||||
parentPID := flag.Int("parent", 0, "exit when this process exits")
|
||||
logFile := flag.String("log", "", "log file (default: standard error)")
|
||||
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")
|
||||
flag.Parse()
|
||||
if *version {
|
||||
@@ -48,13 +54,35 @@ func main() {
|
||||
out = f
|
||||
}
|
||||
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)
|
||||
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)
|
||||
if _, err := rand.Read(secret); err != nil {
|
||||
return err
|
||||
@@ -78,8 +106,15 @@ func run(logger *log.Logger, listen, ready string, parentPID int, trace bool) er
|
||||
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{
|
||||
Handler: &proxy.Server{Token: token, Log: logger, Trace: trace},
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 30 * time.Second,
|
||||
IdleTimeout: 2 * time.Minute,
|
||||
ErrorLog: logger,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
module git.t5de.dev/t5de/t5de-proxy
|
||||
|
||||
go 1.27.0
|
||||
|
||||
require git.t5de.dev/t5de/t5de-api v0.0.0-20261029101713-106b4fcc6064
|
||||
@@ -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=
|
||||
@@ -40,10 +40,11 @@ func TypeName(t uint64) string {
|
||||
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 uint64
|
||||
Size int
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// 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.
|
||||
const maxFrame = 16 << 20
|
||||
|
||||
@@ -116,6 +123,7 @@ func parse(b []byte) (Frame, int, error) {
|
||||
return f, 0, nil
|
||||
}
|
||||
f.Size = int(size)
|
||||
f.Data = b[i : i+int(size) : i+int(size)]
|
||||
return f, i + int(size), nil
|
||||
default:
|
||||
return f, 0, ErrMalformed
|
||||
|
||||
@@ -36,12 +36,12 @@ func TestReaderSplitsFrames(t *testing.T) {
|
||||
}
|
||||
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) {
|
||||
t.Fatalf("got %v, want %v", got, 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)
|
||||
}
|
||||
}
|
||||
@@ -63,3 +63,21 @@ func TestTypeName(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package products
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func community(id int64) bool { return id >= 2000000000 }
|
||||
|
||||
func TestStripCommand(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
in, out string
|
||||
empty, outfit bool
|
||||
products string
|
||||
}{
|
||||
{"*use 80 123 2000000001 456", "*use 80 123 456", false, true, "[2000000001]"},
|
||||
{"*use 2000000001", "*use", true, false, "[2000000001]"},
|
||||
{"*putOnOutfit 5 2000000002 2000000003", "*putOnOutfit 5", false, false, "[2000000002 2000000003]"},
|
||||
{"*remove 2000000001", "*remove", true, false, "[2000000001]"},
|
||||
{"*use 80 123", "*use 80 123", false, true, "[]"},
|
||||
{"hello 2000000001", "hello 2000000001", false, false, "[]"},
|
||||
{"*imvu:setRoomState c...", "*imvu:setRoomState c...", false, false, "[]"},
|
||||
} {
|
||||
r := stripCommand(c.in, community)
|
||||
if r.text != c.out || r.empty != c.empty || r.outfit != c.outfit || fmt.Sprint(r.products) != c.products {
|
||||
t.Errorf("%q: got %+v", c.in, r)
|
||||
}
|
||||
if r.changed != (len(r.products) > 0) {
|
||||
t.Errorf("%q: changed %v", c.in, r.changed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessage(t *testing.T) {
|
||||
m, ok := parseChatMessage([]byte(`{"to": 0, "message": "*use 80 2000000001", "userId": 392, "chatId": 77}`))
|
||||
if !ok || m.text != "*use 80 2000000001" || m.userID != 392 {
|
||||
t.Fatalf("%+v %v", m, ok)
|
||||
}
|
||||
out := string(m.withText("*use 80"))
|
||||
if out != `{"chatId":77,"message":"*use 80","to":0,"userId":392}` {
|
||||
t.Fatal(out)
|
||||
}
|
||||
if _, ok := parseChatMessage([]byte(`not json`)); ok {
|
||||
t.Fatal("parsed non-JSON")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -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("&", "&", "<", "<", ">", ">", `"`, """, "'", "'")
|
||||
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)")
|
||||
@@ -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 <Coat></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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package products
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// IMVU signs the client's requests: X-imvu-auth is md5(user ID + session security key + body), with the query
|
||||
// string added for some calls. The proxy re-signs requests it changes (taking community products out), with the key
|
||||
// from the login response. The key never leaves this machine.
|
||||
|
||||
func sign(user, key string, body []byte, query string, withQuery bool) string {
|
||||
h := md5.New()
|
||||
h.Write([]byte(user + key))
|
||||
h.Write(body)
|
||||
if withQuery {
|
||||
h.Write([]byte(query))
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// keys holds each signed-in user's security key.
|
||||
type keys struct {
|
||||
mu sync.Mutex
|
||||
byID map[string]string
|
||||
}
|
||||
|
||||
var securityKeyPattern = regexp.MustCompile(`<name>securityKey</name>\s*<value>(?:<string>)?([0-9A-Za-z]+)`)
|
||||
var userIDPattern = regexp.MustCompile(`<name>userId</name>\s*<value>(?:<int>|<i4>|<string>)?([0-9]+)`)
|
||||
|
||||
// learn takes the user's key from a login response (test.avatarInfoForLogin2).
|
||||
func (k *keys) learn(body []byte) {
|
||||
key, user := securityKeyPattern.FindSubmatch(body), userIDPattern.FindSubmatch(body)
|
||||
if key == nil || user == nil {
|
||||
return
|
||||
}
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if k.byID == nil {
|
||||
k.byID = map[string]string{}
|
||||
}
|
||||
k.byID[string(user[1])] = string(key[1])
|
||||
}
|
||||
|
||||
// resign recomputes X-imvu-auth for a request whose body or query changed, in the scheme the original used. It
|
||||
// reports false if it couldn't (no key, or the original signature didn't match either scheme).
|
||||
func (k *keys) resign(h http.Header, oldBody []byte, oldQuery string, newBody []byte, newQuery string) bool {
|
||||
auth, user := h.Get("X-imvu-auth"), h.Get("X-imvu-userid")
|
||||
if auth == "" {
|
||||
return true // not signed
|
||||
}
|
||||
k.mu.Lock()
|
||||
key, ok := k.byID[user]
|
||||
k.mu.Unlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, withQuery := range []bool{false, true} {
|
||||
if sign(user, key, oldBody, oldQuery, withQuery) == auth {
|
||||
h.Set("X-imvu-auth", sign(user, key, newBody, newQuery, withQuery))
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,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
@@ -65,8 +65,11 @@ func (s *Server) serveConnect(w http.ResponseWriter, r *http.Request) {
|
||||
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"}
|
||||
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.
|
||||
var once sync.Once
|
||||
closeBoth := func() {
|
||||
@@ -85,21 +88,43 @@ func (s *Server) serveConnect(w http.ResponseWriter, r *http.Request) {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer closeBoth()
|
||||
down.copy(conn, upstream)
|
||||
down.copy(toClient, upstream)
|
||||
}()
|
||||
wg.Wait()
|
||||
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 {
|
||||
s *Server
|
||||
id int64
|
||||
name string
|
||||
bytes int64
|
||||
frames int
|
||||
other bool // not IMQ framing
|
||||
reader imq.Reader
|
||||
s *Server
|
||||
id int64
|
||||
name string
|
||||
bytes int64
|
||||
frames int
|
||||
other bool // not IMQ framing
|
||||
pending []byte
|
||||
filter IMQFilter // outgoing only
|
||||
reply io.Writer // outgoing only: to the client
|
||||
}
|
||||
|
||||
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 {
|
||||
n, err := src.Read(buf)
|
||||
if n > 0 {
|
||||
st.observe(buf[:n])
|
||||
if _, werr := dst.Write(buf[:n]); werr != nil {
|
||||
st.bytes += int64(n)
|
||||
if werr := st.pass(dst, buf[:n]); werr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -118,22 +143,65 @@ func (st *stream) copy(dst io.Writer, src io.Reader) {
|
||||
}
|
||||
}
|
||||
|
||||
func (st *stream) observe(data []byte) {
|
||||
st.bytes += int64(len(data))
|
||||
// pass writes on the frames data completes.
|
||||
func (st *stream) pass(dst io.Writer, data []byte) error {
|
||||
if st.other {
|
||||
return
|
||||
_, err := dst.Write(data)
|
||||
return err
|
||||
}
|
||||
frames, err := st.reader.Write(data)
|
||||
st.frames += len(frames)
|
||||
if st.s.Trace {
|
||||
for _, f := range frames {
|
||||
st.pending = append(st.pending, data...)
|
||||
var out []byte
|
||||
for {
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
st.other = true
|
||||
st.s.logf("#%d %s isn't IMQ framing; passing it through", st.id, st.name)
|
||||
return frame
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -14,6 +14,7 @@ package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
@@ -24,6 +25,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"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
|
||||
// default, with the system's roots.
|
||||
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 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)
|
||||
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)
|
||||
if err != nil && upgraded && replayable && r.Context().Err() == nil {
|
||||
@@ -191,6 +220,11 @@ func (s *Server) forward(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
const maxBuffered = 32 << 20
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package proxy
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
@@ -18,6 +19,8 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.t5de.dev/t5de/t5de-proxy/internal/imq"
|
||||
)
|
||||
|
||||
const token = "secret"
|
||||
@@ -340,3 +343,134 @@ func TestFallsBackToHTTPWithTheBody(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 & <name> </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 ☺</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<&></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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user