Compare commits
35
Commits
2551888ed6
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ae45fc07a | ||
|
|
8daf91ede3 | ||
|
|
6aac769246 | ||
|
|
56c47e806f | ||
|
|
2df46da3ad | ||
|
|
210bc064e9 | ||
|
|
fc59367ba6 | ||
|
|
d379d848c1 | ||
|
|
08a2ed94a3 | ||
|
|
c483099558 | ||
|
|
13ee023836 | ||
|
|
0be2cd8f4e | ||
|
|
d8332b21a8 | ||
|
|
455dae1013 | ||
|
|
171e8173af | ||
|
|
af9158513b | ||
|
|
2280ad6314 | ||
|
|
6506c641f0 | ||
|
|
400831bf3c | ||
|
|
5eca0b57a6 | ||
|
|
5b4e44c4e7 | ||
|
|
ca7a86ce89 | ||
|
|
c281fb572c | ||
|
|
93fa2f5d90 | ||
|
|
e394f1dc24 | ||
|
|
bfd25e2b84 | ||
|
|
7887eada61 | ||
|
|
50e4db8521 | ||
|
|
92103098f8 | ||
|
|
0e5ecf54ae | ||
|
|
887e8a2364 | ||
|
|
b2eed27b99 | ||
|
|
0c4f6caec1 | ||
|
|
bee95533c0 | ||
|
|
2190230f81 |
No files matched your search
@@ -12,6 +12,9 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
# T5DE's modules (t5de-api) come straight from its Gitea, not through Go's public module proxy.
|
||||||
|
GOPRIVATE: git.t5de.dev
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -24,3 +24,11 @@ in the `t5de` repository (git.t5de.dev/t5de/t5de, usually checked out beside thi
|
|||||||
- t5de's `tools/drive.py` starts the built client, takes screenshots, clicks, and sends `imvu:` commands
|
- t5de's `tools/drive.py` starts the built client, takes screenshots, clicks, and sends `imvu:` commands
|
||||||
(`showMode?mode=shop`, ...), so the proxy can be tested in the real client without a person; the test
|
(`showMode?mode=shop`, ...), so the proxy can be tested in the real client without a person; the test
|
||||||
account's saved login signs in by itself. The log is `%TEMP%\t5de-proxy.log`.
|
account's saved login signs in by itself. The log is `%TEMP%\t5de-proxy.log`.
|
||||||
|
- Community products (README): `internal/products`. What the client needs was worked out from captures
|
||||||
|
(t5de-hub's `docs/imq-rooms.md` and this repo's README): a product's CFL holds `index.xml` (deriving from a
|
||||||
|
parent with `__DATAIMPORT product://<id>/index.xml`), materials and textures; `_contents.json` would make the
|
||||||
|
client fetch files separately, so packs don't use it. Outfit saves happen only from some modes (Dress Up, not
|
||||||
|
rooms). Test packs (never committed: they're built on IMVU products' templates) live outside the repo; tests use
|
||||||
|
synthetic files.
|
||||||
|
- Debugging: `-trace` (or `T5DE_PROXY_TRACE=1`) logs full URLs and IMQ frame types; changes to community products
|
||||||
|
are logged as `products: ...`.
|
||||||
@@ -11,6 +11,7 @@ connects to IMVU through it:
|
|||||||
- **IMQ**, IMVU's chat and presence connection: the client speaks plaintext to the proxy, which connects to the
|
- **IMQ**, IMVU's chat and presence connection: the client speaks plaintext to the proxy, which connects to the
|
||||||
gateway over TLS.
|
gateway over TLS.
|
||||||
- **IMVU's reporting endpoints refused**, as T5DE's patches already do in the client.
|
- **IMVU's reporting endpoints refused**, as T5DE's patches already do in the client.
|
||||||
|
- **Community products** from local packs (t5de-api's pack format), kept away from IMVU (below).
|
||||||
|
|
||||||
If the proxy isn't there or stops, the client connects directly, as before.
|
If the proxy isn't there or stops, the client connects directly, as before.
|
||||||
|
|
||||||
@@ -32,6 +33,81 @@ The client starts it as `t5de-proxy -parent <pid> -ready <file> -log %TEMP%\t5de
|
|||||||
address and token to the ready file once listening, and exits with the client. Setting `T5DE_PROXY_TRACE=1`
|
address and token to the ready file once listening, and exits with the client. Setting `T5DE_PROXY_TRACE=1`
|
||||||
before starting the client adds full URLs (with queries) and every IMQ frame to the log.
|
before starting the client adds full URLs (with queries) and every IMQ frame to the log.
|
||||||
|
|
||||||
|
## Community products
|
||||||
|
|
||||||
|
Phase 1 of T5DE's community products (design: t5de-hub's `docs/design.md`): products in local packs, a folder
|
||||||
|
each, in `%APPDATA%\T5DE\packs` and the folders in `T5DE_PACKS` (separated by `;`). Each product gets a local ID
|
||||||
|
from 2,000,000,000 up, kept in `%APPDATA%\T5DE\proxy\products.json`. For those IDs the proxy answers the client
|
||||||
|
itself:
|
||||||
|
|
||||||
|
- `test.inventoryProductBatch` (product info) and `test.authorizeProducts2` (permission, the CFL's address and
|
||||||
|
checksum): for a product deriving from an IMVU product, IMVU is asked about the parent and the proxy adds its own
|
||||||
|
entry (the client needs the parent's derivation chain).
|
||||||
|
- `productdata/<local ID>/<revision>`: the pack built into a CFL.
|
||||||
|
- `api/shop/product.php`: putting a product on asks whether it's a bundle (not), and the shop asks for product
|
||||||
|
details (answered from the hub's listing, or the pack; IMVU is asked only about its own products).
|
||||||
|
|
||||||
|
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.
|
||||||
|
The client sends some product IDs as floats (`2000000008.0`), so every ID is read either way (`parseID`). As a
|
||||||
|
last check, `products.Guard` refuses any request to IMVU that still names a local ID given out, and logs it
|
||||||
|
(`products: refused a request to ...`): such a request is a path not handled yet.
|
||||||
|
|
||||||
|
## The hub
|
||||||
|
|
||||||
|
Which hub: `$T5DE_HUB` if set, else the one chosen in the client's Settings > T5DE (kept in
|
||||||
|
`%APPDATA%\T5DE\proxy\server.json`, used from the next start), else the install's channel's (`-channel`, which
|
||||||
|
the client passes): T5DE's hub (`https://hub.t5de.dev`) for releases, staging (`https://hub.staging.t5de.dev`) for
|
||||||
|
nightlies and builds outside an install (`dev`). What belongs to a hub (its sign-in, product IDs, its products) is
|
||||||
|
kept per hub: in `%APPDATA%\T5DE\proxy` for T5DE's hub, in `hubs\<host>` under it for others, so a hub never gets
|
||||||
|
another's sign-in, and product IDs from two hubs never clash.
|
||||||
|
|
||||||
|
The client's Settings > T5DE page signs in and out (the proxy answers it at `http://t5de-proxy.local/hub`,
|
||||||
|
`internal/control`). `t5de-proxy signin [-hub URL]` also signs the proxy in to T5DE's hub (git.t5de.dev/t5de/t5de-hub; URL is the hub's
|
||||||
|
API): it shows a code to enter on the hub's website (`/device`, OAuth 2.0 device authorization) and saves the
|
||||||
|
session token it gets in `hub.json` in the hub's folder, encrypted for the Windows user (DPAPI). From then on the
|
||||||
|
running proxy keeps a WebSocket to the hub (`internal/hub`) with the token as a bearer token, reporting which IMVU
|
||||||
|
account the client signed in as, the community products it wears, and the IMVU chats it's in. If the hub ends the
|
||||||
|
session (signed out everywhere, banned), the proxy stops connecting until `signin` is run again.
|
||||||
|
|
||||||
|
The room channel (`internal/products/room.go`): the hub tells the proxy which other T5DE users are in the same chat
|
||||||
|
and what they wear; the proxy gives the client those of their products it has packs for, as `*use`/`*remove` lines
|
||||||
|
from them, and adds them to the whole outfits they send. It also keeps community products out of the outfit the
|
||||||
|
client posts to IMVU's chat participant endpoint.
|
||||||
|
|
||||||
|
The shop (the client's `t5de-shop` patch adds a T5 choice to its rating filter): searches with
|
||||||
|
`maturity_rating` `t5` (`api/shop/search_st.php`) are answered from the hub's products (`GET /api/v1/products`,
|
||||||
|
filtered by the shop's categories against each product's IMVU parent's category path), fetching listed products
|
||||||
|
that aren't here yet (up to 15 seconds) so they can be shown and tried on. Buying T5 products
|
||||||
|
(`api/service/shop/svc_catalog_purchase.php`) adds them to the person's hub library; IMVU products in the same
|
||||||
|
purchase go on to IMVU without them.
|
||||||
|
|
||||||
|
T5DE-hosted rooms (`internal/hosted`, t5de-hub's `docs/hosted-rooms.md`): rooms living on the hub, with instance IDs
|
||||||
|
`2100000000-<n>` and chat IDs `2100000000+n`. The proxy answers the client's room calls for them (`getRoomCardInfo`,
|
||||||
|
`getOrMakeChat`, `getRoomState`, `setRoomState`, `getParticipants`, `terminateChat`), serves their IMQ queues
|
||||||
|
(`/chat/<id>`, `inv:/room/room-<id>`, `room_occupancy/<id>`: subscriptions answered here through `proxy.IMQRouter`,
|
||||||
|
chat to and from the hub over its WebSocket), adds them to the room list, checks the room's products against its real
|
||||||
|
owner (`authorizeProducts2`), and answers any other request to IMVU naming one ("not found") rather than send it.
|
||||||
|
|
||||||
|
Decorations (`internal/products/decor.go`): community furniture and poses in IMVU rooms. They're taken out of the
|
||||||
|
room its owner saves (`test.setRoomState`) and broadcasts (`*imvu:setRoomState`, a zlib-compressed scene) so IMVU
|
||||||
|
keeps the base room, and stored on the hub for that room (`PUT /api/v1/decorations/<instance>`; placements name their
|
||||||
|
product, stored as the hub's ID). Loading the room (`test.getRoomState`, the hub asked at the same time) and the owner's
|
||||||
|
incoming broadcasts get them added back as local IDs; the hub's `decor` notice makes the proxy give the client the room
|
||||||
|
again with the new ones.
|
||||||
|
|
||||||
|
`T5DE_INSTANCE=<name>` (set by the client's `t5de-instances` patch for a second client run beside the first) keeps
|
||||||
|
the proxy's data in `%APPDATA%\T5DE-<name>\proxy` and its log in `t5de-proxy-<name>.log`.
|
||||||
|
|
||||||
|
For finding out what IMVU's APIs send, `T5DE_PROXY_DUMP=<text>` logs the request and response bodies of requests
|
||||||
|
whose path contains it (test accounts only: bodies hold personal data). In Git Bash, keep the value from starting
|
||||||
|
with `/` (or set `MSYS_NO_PATHCONV=1`), or it's turned into a Windows path.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
go test ./...
|
go test ./...
|
||||||
|
|||||||
+217
-3
@@ -5,32 +5,64 @@
|
|||||||
//
|
//
|
||||||
// It listens on a free port of 127.0.0.1 and, once it's listening, writes {"address", "token"} to the ready file
|
// It listens on a free port of 127.0.0.1 and, once it's listening, writes {"address", "token"} to the ready file
|
||||||
// (written whole, by renaming), which the client reads.
|
// (written whole, by renaming), which the client reads.
|
||||||
|
//
|
||||||
|
// t5de-proxy signin [-hub URL]
|
||||||
|
//
|
||||||
|
// signs the proxy in to T5DE's hub: it shows a code to enter on the hub's website and saves the sign-in (encrypted
|
||||||
|
// for the Windows user). From then on the proxy connects to the hub as it runs.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.t5de.dev/t5de/t5de-proxy/internal/buildinfo"
|
"git.t5de.dev/t5de/t5de-proxy/internal/buildinfo"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/cards"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/control"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/fetch"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/hosted"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/imq"
|
||||||
"git.t5de.dev/t5de/t5de-proxy/internal/parent"
|
"git.t5de.dev/t5de/t5de-proxy/internal/parent"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/products"
|
||||||
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "signin" {
|
||||||
|
if err := signin(os.Args[2:]); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "t5de-proxy signin:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
listen := flag.String("listen", "127.0.0.1:0", "address to listen on")
|
listen := flag.String("listen", "127.0.0.1:0", "address to listen on")
|
||||||
ready := flag.String("ready", "", "file to write the address and token to once listening")
|
ready := flag.String("ready", "", "file to write the address and token to once listening")
|
||||||
parentPID := flag.Int("parent", 0, "exit when this process exits")
|
parentPID := flag.Int("parent", 0, "exit when this process exits")
|
||||||
logFile := flag.String("log", "", "log file (default: standard error)")
|
logFile := flag.String("log", "", "log file (default: standard error)")
|
||||||
trace := flag.Bool("trace", false, "log full URLs and every IMQ frame")
|
trace := flag.Bool("trace", false, "log full URLs and every IMQ frame")
|
||||||
|
packs := flag.String("packs", "", "community product packs: directories of packs, separated by "+string(filepath.ListSeparator)+
|
||||||
|
" (default: %APPDATA%/T5DE/packs and $T5DE_PACKS)")
|
||||||
|
data := flag.String("data", "", "where the proxy keeps its state (default: %APPDATA%/T5DE/proxy, or T5DE-<n> for $T5DE_INSTANCE)")
|
||||||
|
channel := flag.String("channel", "release", "the install's channel (release, nightly, or dev outside an install): nightly and dev use T5DE's staging hub unless the person chooses another")
|
||||||
version := flag.Bool("version", false, "print the version and exit")
|
version := flag.Bool("version", false, "print the version and exit")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
if *version {
|
if *version {
|
||||||
@@ -40,6 +72,10 @@ func main() {
|
|||||||
|
|
||||||
var out io.Writer = os.Stderr
|
var out io.Writer = os.Stderr
|
||||||
if *logFile != "" {
|
if *logFile != "" {
|
||||||
|
if instance := os.Getenv("T5DE_INSTANCE"); instance != "" {
|
||||||
|
// Another client beside the usual one (its T5DE_INSTANCE): its own log.
|
||||||
|
*logFile = strings.TrimSuffix(*logFile, ".log") + "-" + instance + ".log"
|
||||||
|
}
|
||||||
f, err := os.Create(*logFile)
|
f, err := os.Create(*logFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -48,13 +84,38 @@ func main() {
|
|||||||
out = f
|
out = f
|
||||||
}
|
}
|
||||||
logger := log.New(out, "", log.Ldate|log.Ltime|log.Lmicroseconds)
|
logger := log.New(out, "", log.Ldate|log.Ltime|log.Lmicroseconds)
|
||||||
if err := run(logger, *listen, *ready, *parentPID, *trace); err != nil {
|
if err := run(logger, *listen, *ready, *parentPID, *trace, packDirs(*packs), dataDir(*data), *channel); err != nil {
|
||||||
logger.Printf("t5de-proxy: %v", err)
|
logger.Printf("t5de-proxy: %v", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(logger *log.Logger, listen, ready string, parentPID int, trace bool) error {
|
// packDirs is where community product packs are: the -packs flag, else T5DE's packs folder and $T5DE_PACKS.
|
||||||
|
func packDirs(flagValue string) []string {
|
||||||
|
if flagValue != "" {
|
||||||
|
return filepath.SplitList(flagValue)
|
||||||
|
}
|
||||||
|
var dirs []string
|
||||||
|
if config, err := os.UserConfigDir(); err == nil {
|
||||||
|
dirs = append(dirs, filepath.Join(config, "T5DE", "packs"))
|
||||||
|
}
|
||||||
|
return append(dirs, filepath.SplitList(os.Getenv("T5DE_PACKS"))...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dataDir(flagValue string) string {
|
||||||
|
if flagValue != "" {
|
||||||
|
return flagValue
|
||||||
|
}
|
||||||
|
if config, err := os.UserConfigDir(); err == nil {
|
||||||
|
if instance := os.Getenv("T5DE_INSTANCE"); instance != "" {
|
||||||
|
return filepath.Join(config, "T5DE-"+instance, "proxy") // the client's own data (its T5DE_INSTANCE)
|
||||||
|
}
|
||||||
|
return filepath.Join(config, "T5DE", "proxy")
|
||||||
|
}
|
||||||
|
return "."
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(logger *log.Logger, listen, ready string, parentPID int, trace bool, packs []string, data, channel string) error {
|
||||||
secret := make([]byte, 24)
|
secret := make([]byte, 24)
|
||||||
if _, err := rand.Read(secret); err != nil {
|
if _, err := rand.Read(secret); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -78,8 +139,95 @@ func run(logger *log.Logger, listen, ready string, parentPID int, trace bool) er
|
|||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
handler := &proxy.Server{Token: token, Log: logger, Trace: trace}
|
||||||
|
// The hub: the person's choice (the client's Settings), else the install's channel's. Everything that belongs
|
||||||
|
// to a hub (sign-in, product IDs, its products) is kept in its own folder.
|
||||||
|
choice := filepath.Join(data, "server.json")
|
||||||
|
hubURL := control.Server(choice, channel, os.Getenv("T5DE_HUB"))
|
||||||
|
state := control.StateDir(data, hubURL)
|
||||||
|
logger.Printf("hub %s (channel %s)", hubURL, channel)
|
||||||
|
// Products from the hub's catalogs are kept as packs in their own folder, served like the others.
|
||||||
|
hubProducts := filepath.Join(state, "hub-products")
|
||||||
|
if err := os.MkdirAll(hubProducts, 0o755); err != nil {
|
||||||
|
logger.Printf("hub products: %v", err)
|
||||||
|
}
|
||||||
|
prods, err := products.New(append(packs, hubProducts), state, logger.Printf)
|
||||||
|
if 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, ", "))
|
||||||
|
}
|
||||||
|
// T5DE-hosted rooms (internal/hosted), served with the hub the proxy is signed in to.
|
||||||
|
hubNow := &hubRef{}
|
||||||
|
rooms := &hosted.Rooms{Hub: hubNow, Logf: logger.Printf}
|
||||||
|
if prods != nil {
|
||||||
|
prods.Hosted = rooms
|
||||||
|
rooms.User, rooms.LocalProduct, rooms.GlobalProduct = prods.User, prods.LocalID, prods.ProductID
|
||||||
|
rooms.UserDeliver = prods.DeliverToUser
|
||||||
|
rooms.Deliver = func(c *proxy.IMQConn, m imq.Delivered) {
|
||||||
|
m.Message = prods.Incoming(c, m)
|
||||||
|
c.Deliver(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The hub connection, which the client's T5DE settings can sign in and out (internal/control).
|
||||||
|
manager := &control.Manager{Path: filepath.Join(state, "hub.json"), Hub: hubURL, Default: control.ChannelDefault(channel),
|
||||||
|
ChoicePath: choice, Fixed: os.Getenv("T5DE_HUB") != "", Log: logger, OpenBrowser: openBrowser}
|
||||||
|
manager.Attach = func(client *hub.Client) {
|
||||||
|
hubNow.set(client)
|
||||||
|
client.OnRoom, client.OnConnect = rooms.FromHub, rooms.Reconnected
|
||||||
|
rooms.OnJoin, rooms.OnLeave = client.Join, client.Leave
|
||||||
|
fetcher := fetch.New(client, hubProducts, logger.Printf)
|
||||||
|
client.OnLibrary = func() { fetcher.SyncLibrary(context.Background()) }
|
||||||
|
if prods == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prods.Observer = client
|
||||||
|
client.OnChat = prods.Others
|
||||||
|
fetcher.OnReady = prods.Ready
|
||||||
|
fetcher.OnLibrary = prods.RefreshInventory
|
||||||
|
prods.Want = fetcher.Want
|
||||||
|
prods.HubDir, prods.Owned = hubProducts, fetcher.Owned
|
||||||
|
prods.Shop, prods.Fetch, prods.Library = client.Products, fetcher.Ensure, fetcher.Library
|
||||||
|
prods.Decorations = func(ctx context.Context, instance string) ([]products.DecorItem, error) {
|
||||||
|
var out struct {
|
||||||
|
Contents []products.DecorItem `json:"contents"`
|
||||||
|
}
|
||||||
|
err := client.Call(ctx, "GET", "/api/v1/decorations/"+url.PathEscape(instance), nil, &out)
|
||||||
|
return out.Contents, err
|
||||||
|
}
|
||||||
|
prods.SaveDecorations = func(ctx context.Context, instance string, items []products.DecorItem) error {
|
||||||
|
if items == nil {
|
||||||
|
items = []products.DecorItem{}
|
||||||
|
}
|
||||||
|
return client.Call(ctx, "PUT", "/api/v1/decorations/"+url.PathEscape(instance), map[string]any{"contents": items}, nil)
|
||||||
|
}
|
||||||
|
client.OnDecor = prods.DecorChanged
|
||||||
|
prods.AddToLibrary = func(ctx context.Context, id string) error {
|
||||||
|
return client.Call(ctx, "PUT", "/api/v1/library/"+id, map[string]any{}, nil)
|
||||||
|
}
|
||||||
|
prods.OnHide = func(id string) {
|
||||||
|
if fetch.IsHubID(id) {
|
||||||
|
go func() {
|
||||||
|
if err := client.Call(context.Background(), "DELETE", "/api/v1/library/"+id, nil, nil); err != nil {
|
||||||
|
logger.Printf("hub: removing %s from the library: %v", id, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
manager.Start()
|
||||||
|
chain := proxy.Interceptors{manager, rooms, &cards.Cards{}} // hosted rooms' calls before the products'
|
||||||
|
if prods != nil {
|
||||||
|
chain = append(chain, prods)
|
||||||
|
}
|
||||||
|
chain = append(chain, proxy.Dump{Match: os.Getenv("T5DE_PROXY_DUMP"), Log: logger})
|
||||||
|
if prods != nil {
|
||||||
|
chain = append(chain, products.Guard{Products: prods}) // last: nothing naming a community product reaches IMVU
|
||||||
|
}
|
||||||
|
handler.Interceptor = chain
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Handler: &proxy.Server{Token: token, Log: logger, Trace: trace},
|
Handler: handler,
|
||||||
ReadHeaderTimeout: 30 * time.Second,
|
ReadHeaderTimeout: 30 * time.Second,
|
||||||
IdleTimeout: 2 * time.Minute,
|
IdleTimeout: 2 * time.Minute,
|
||||||
ErrorLog: logger,
|
ErrorLog: logger,
|
||||||
@@ -87,6 +235,38 @@ func run(logger *log.Logger, listen, ready string, parentPID int, trace bool) er
|
|||||||
return server.Serve(ln)
|
return server.Serve(ln)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hubRef is the hub client the proxy is signed in with, for the hosted rooms (none while signed out).
|
||||||
|
type hubRef struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
c *hub.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *hubRef) set(c *hub.Client) {
|
||||||
|
h.mu.Lock()
|
||||||
|
h.c = c
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *hubRef) get() *hub.Client {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
return h.c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *hubRef) Send(v any) bool {
|
||||||
|
if c := h.get(); c != nil {
|
||||||
|
return c.Send(v)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *hubRef) Call(ctx context.Context, method, path string, body, out any) error {
|
||||||
|
if c := h.get(); c != nil {
|
||||||
|
return c.Call(ctx, method, path, body, out)
|
||||||
|
}
|
||||||
|
return errors.New("not signed in to T5DE's hub")
|
||||||
|
}
|
||||||
|
|
||||||
func writeReady(path, address, token string) error {
|
func writeReady(path, address, token string) error {
|
||||||
data, err := json.Marshal(map[string]string{"address": address, "token": token})
|
data, err := json.Marshal(map[string]string{"address": address, "token": token})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -98,3 +278,37 @@ func writeReady(path, address, token string) error {
|
|||||||
}
|
}
|
||||||
return os.Rename(tmp, path)
|
return os.Rename(tmp, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func signin(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("signin", flag.ExitOnError)
|
||||||
|
hubURL := fs.String("hub", envOr("T5DE_HUB", control.Production), "the hub's address (or $T5DE_HUB)")
|
||||||
|
data := fs.String("data", "", "where the proxy keeps its state (default: %APPDATA%/T5DE/proxy)")
|
||||||
|
fs.Parse(args)
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
|
defer stop()
|
||||||
|
saved, err := hub.SignIn(ctx, *hubURL, func(code, page string) {
|
||||||
|
fmt.Printf("To sign T5DE in, enter this code at %s:\n\n %s\n\nOpening it in your browser...\n", page, code)
|
||||||
|
openBrowser(page)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := saved.Save(filepath.Join(control.StateDir(dataDir(*data), strings.TrimSuffix(*hubURL, "/")), "hub.json")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("Signed in. T5DE connects to the hub the next time the client starts.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(name, fallback string) string {
|
||||||
|
if v := os.Getenv(name); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func openBrowser(page string) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
exec.Command("rundll32", "url.dll,FileProtocolHandler", page).Start()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
module git.t5de.dev/t5de/t5de-proxy
|
module git.t5de.dev/t5de/t5de-proxy
|
||||||
|
|
||||||
go 1.27.0
|
go 1.27.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
git.t5de.dev/t5de/t5de-api v0.0.0-20261029101713-106b4fcc6064
|
||||||
|
github.com/coder/websocket v1.8.15
|
||||||
|
golang.org/x/sys v0.49.0
|
||||||
|
)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
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=
|
||||||
|
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||||
|
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
|
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||||
|
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// Package cards fixes what IMVU's servers no longer send the client's avatar cards: the AP+ badge. The card shows it
|
||||||
|
// when avatarcard.php's answer has show_ap_plus, which IMVU stopped sending; whether someone has AP+ is public on
|
||||||
|
// their IMVU profile (api.imvu.com/user/user-<id>, is_ap_plus), as the friends list and Find People still show.
|
||||||
|
package cards
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// keep is how long an account's AP+ is remembered.
|
||||||
|
const keep = 10 * time.Minute
|
||||||
|
|
||||||
|
// Cards adds show_ap_plus to avatar cards of people with AP+.
|
||||||
|
type Cards struct {
|
||||||
|
// Client fetches IMVU's public profiles (nil: http.DefaultClient).
|
||||||
|
Client *http.Client
|
||||||
|
// Base is IMVU's API ("" for https://api.imvu.com).
|
||||||
|
Base string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
known map[int64]apPlus
|
||||||
|
}
|
||||||
|
|
||||||
|
type apPlus struct {
|
||||||
|
has bool
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cards) Intercept(x *proxy.Exchange) error {
|
||||||
|
if !strings.EqualFold(x.URL.Hostname(), "client-dynamic.imvu.com") || x.URL.Path != "/api/avatarcard.php" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cid, err := strconv.ParseInt(x.URL.Query().Get("cid"), 10, 64)
|
||||||
|
if err != nil || cid <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
|
||||||
|
var card map[string]json.RawMessage
|
||||||
|
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &card) != nil || set(card["show_ap_plus"]) {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if !c.has(ctx, cid) {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
card["show_ap_plus"] = json.RawMessage("1")
|
||||||
|
return json.Marshal(card)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// set reports whether a JSON value counts as set in the card's JavaScript (truthy).
|
||||||
|
func set(v json.RawMessage) bool {
|
||||||
|
s := strings.TrimSpace(string(v))
|
||||||
|
return s != "" && s != "null" && s != "false" && s != "0" && s != `""` && s != `"0"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// has reports whether an account has AP+ (false if IMVU can't say).
|
||||||
|
func (c *Cards) has(ctx context.Context, id int64) bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
k, ok := c.known[id]
|
||||||
|
c.mu.Unlock()
|
||||||
|
if ok && time.Since(k.at) < keep {
|
||||||
|
return k.has
|
||||||
|
}
|
||||||
|
base := c.Base
|
||||||
|
if base == "" {
|
||||||
|
base = "https://api.imvu.com"
|
||||||
|
}
|
||||||
|
u := fmt.Sprintf("%s/user/user-%d", base, id)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
client := c.Client
|
||||||
|
if client == nil {
|
||||||
|
client = http.DefaultClient
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return k.has
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var profile struct {
|
||||||
|
Denormalized map[string]struct {
|
||||||
|
Data struct {
|
||||||
|
IsAPPlus bool `json:"is_ap_plus"`
|
||||||
|
} `json:"data"`
|
||||||
|
} `json:"denormalized"`
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&profile) != nil {
|
||||||
|
return k.has
|
||||||
|
}
|
||||||
|
has := profile.Denormalized[fmt.Sprintf("https://api.imvu.com/user/user-%d", id)].Data.IsAPPlus
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.known == nil {
|
||||||
|
c.known = map[int64]apPlus{}
|
||||||
|
}
|
||||||
|
c.known[id] = apPlus{has: has, at: time.Now()}
|
||||||
|
c.mu.Unlock()
|
||||||
|
return has
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package cards
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAPPlusBadge(t *testing.T) {
|
||||||
|
asked := 0
|
||||||
|
imvu := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
asked++
|
||||||
|
id := strings.TrimPrefix(r.URL.Path, "/user/user-")
|
||||||
|
fmt.Fprintf(w, `{"denormalized": {"https://api.imvu.com/user/user-%s": {"data": {"is_ap_plus": %v}}}}`, id, id == "5")
|
||||||
|
}))
|
||||||
|
defer imvu.Close()
|
||||||
|
c := &Cards{Base: imvu.URL}
|
||||||
|
card := func(cid, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
u, _ := url.Parse("https://client-dynamic.imvu.com/api/avatarcard.php?cid=" + cid + "&viewer_cid=1")
|
||||||
|
x := &proxy.Exchange{URL: u}
|
||||||
|
if err := c.Intercept(x); err != nil || x.ModifyResponse == nil {
|
||||||
|
t.Fatalf("not intercepted: %v", err)
|
||||||
|
}
|
||||||
|
out, err := x.ModifyResponse(&http.Response{StatusCode: 200}, []byte(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
if out := card("5", `{"avatarname": "Ann", "show_ap": 1}`); !strings.Contains(out, `"show_ap_plus":1`) || !strings.Contains(out, `"avatarname":"Ann"`) {
|
||||||
|
t.Fatalf("AP+ card %s", out)
|
||||||
|
}
|
||||||
|
if out := card("6", `{"avatarname": "Bo"}`); strings.Contains(out, "show_ap_plus") {
|
||||||
|
t.Fatalf("no AP+ %s", out)
|
||||||
|
}
|
||||||
|
card("5", `{}`)
|
||||||
|
if asked != 2 {
|
||||||
|
t.Fatalf("IMVU asked %d times (kept for a while)", asked)
|
||||||
|
}
|
||||||
|
// Already set by IMVU: left alone, IMVU not asked.
|
||||||
|
if out := card("7", `{"show_ap_plus": true}`); out != `{"show_ap_plus": true}` || asked != 2 {
|
||||||
|
t.Fatalf("set already %s (%d)", out, asked)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
// Package control runs the proxy's connection to T5DE's hub, and lets the client's T5DE settings page see and
|
||||||
|
// change it: requests to http://t5de-proxy.local/ (only the client can make them: they need the proxy's token)
|
||||||
|
// are answered here.
|
||||||
|
//
|
||||||
|
// GET /hub {signedIn, hub, username, signingIn, code, page, error, choice, default, fixed, restart}
|
||||||
|
// POST /hub/signin starts signing in to this run's hub: opens the hub's page for the code in the browser
|
||||||
|
// POST /hub/signout signs this computer out
|
||||||
|
// POST /hub/server hub=<URL>, or empty for the install's default: the hub from the next start on (restart)
|
||||||
|
// POST /badges {"imvuIds": [...]} -> {"badges": [...]}: which of those IMVU accounts show the T5 badge (their
|
||||||
|
// T5DE users opted in), from the hub, remembered for a while
|
||||||
|
package control
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Host is the address the client uses for the proxy itself.
|
||||||
|
const Host = "t5de-proxy.local"
|
||||||
|
|
||||||
|
// Manager keeps the hub connection running and answers the client's settings page.
|
||||||
|
type Manager struct {
|
||||||
|
Path string // hub.json, in the hub's state folder (StateDir)
|
||||||
|
// Hub is this run's hub (Server); Default the install's (ChannelDefault); ChoicePath where the person's
|
||||||
|
// choice is kept. Fixed means $T5DE_HUB chose it, so the choice can't.
|
||||||
|
Hub, Default, ChoicePath string
|
||||||
|
Fixed bool
|
||||||
|
Log *log.Logger
|
||||||
|
// Attach is called with each new hub client before it runs (to connect it to the rest of the proxy).
|
||||||
|
Attach func(c *hub.Client)
|
||||||
|
// OpenBrowser opens a page in the person's browser.
|
||||||
|
OpenBrowser func(page string)
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
client *hub.Client
|
||||||
|
saved *hub.Saved
|
||||||
|
stop context.CancelFunc
|
||||||
|
username string
|
||||||
|
signin *signin
|
||||||
|
badges map[int64]badgeAnswer
|
||||||
|
}
|
||||||
|
|
||||||
|
// badgeAnswer is whether an IMVU account shows the T5 badge, as the hub said at a time.
|
||||||
|
type badgeAnswer struct {
|
||||||
|
shows bool
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// badgeFor is how long an answer about a badge is kept.
|
||||||
|
var badgeFor = 10 * time.Minute
|
||||||
|
|
||||||
|
// Badges is which of the IMVU accounts show the T5 badge: remembered answers, and the hub asked about the rest.
|
||||||
|
// Signed out, none do.
|
||||||
|
func (m *Manager) Badges(ctx context.Context, ids []int64) []int64 {
|
||||||
|
m.mu.Lock()
|
||||||
|
c := m.client
|
||||||
|
if m.badges == nil {
|
||||||
|
m.badges = map[int64]badgeAnswer{}
|
||||||
|
}
|
||||||
|
var out, ask []int64
|
||||||
|
seen := map[int64]bool{}
|
||||||
|
for _, id := range ids {
|
||||||
|
if id <= 0 || seen[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
if a, ok := m.badges[id]; ok && time.Since(a.at) < badgeFor {
|
||||||
|
if a.shows {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ask = append(ask, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
if c == nil || len(ask) == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for len(ask) > 0 {
|
||||||
|
batch := ask[:min(len(ask), 200)]
|
||||||
|
ask = ask[len(batch):]
|
||||||
|
var r struct {
|
||||||
|
Badges []int64 `json:"badges"`
|
||||||
|
}
|
||||||
|
if err := c.Call(ctx, "POST", "/api/v1/badges", map[string]any{"imvuIds": batch}, &r); err != nil {
|
||||||
|
m.Log.Printf("hub: badges: %v", err)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
shows := map[int64]bool{}
|
||||||
|
for _, id := range r.Badges {
|
||||||
|
shows[id] = true
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
m.mu.Lock()
|
||||||
|
for _, id := range batch {
|
||||||
|
m.badges[id] = badgeAnswer{shows[id], now}
|
||||||
|
if shows[id] {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type signin struct {
|
||||||
|
code, page, err string
|
||||||
|
running bool
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start connects to the hub if the proxy is signed in.
|
||||||
|
func (m *Manager) Start() {
|
||||||
|
saved, err := hub.Load(m.Path)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, hub.ErrSignedOut) {
|
||||||
|
m.Log.Printf("hub: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if saved.URL != m.Hub {
|
||||||
|
// Signed in to another hub (by an older proxy, before hubs had their own folders): not this run's.
|
||||||
|
m.Log.Printf("hub: the sign-in is for %s, not %s; signed out", saved.URL, m.Hub)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.run(saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) run(saved *hub.Saved) {
|
||||||
|
c := hub.New(m.Path, saved, m.Log)
|
||||||
|
if m.Attach != nil {
|
||||||
|
m.Attach(c)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.stop != nil {
|
||||||
|
m.stop()
|
||||||
|
}
|
||||||
|
m.client, m.saved, m.stop, m.username = c, saved, cancel, ""
|
||||||
|
m.mu.Unlock()
|
||||||
|
go c.Run(ctx)
|
||||||
|
go m.lookUpName(ctx, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) lookUpName(ctx context.Context, c *hub.Client) {
|
||||||
|
var me struct {
|
||||||
|
Person struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
} `json:"person"`
|
||||||
|
}
|
||||||
|
if err := c.Call(ctx, "GET", "/api/v1/me", nil, &me); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.client == c {
|
||||||
|
m.username = me.Person.Username
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
type status struct {
|
||||||
|
SignedIn bool `json:"signedIn"`
|
||||||
|
Hub string `json:"hub"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
SigningIn bool `json:"signingIn"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Page string `json:"page"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
Choice string `json:"choice"` // the hub chosen for the next start, "" for the default
|
||||||
|
Default string `json:"default"` // the install's
|
||||||
|
Fixed bool `json:"fixed"` // chosen by $T5DE_HUB
|
||||||
|
Restart bool `json:"restart"` // the next start's hub isn't this run's
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) status() status {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
s := status{Hub: m.Hub, Default: m.Default, Fixed: m.Fixed, Choice: LoadChoice(m.ChoicePath)}
|
||||||
|
next := s.Choice
|
||||||
|
if next == "" {
|
||||||
|
next = m.Default
|
||||||
|
}
|
||||||
|
s.Restart = !m.Fixed && next != m.Hub
|
||||||
|
if m.saved != nil {
|
||||||
|
s.SignedIn, s.Username = true, m.username
|
||||||
|
}
|
||||||
|
if si := m.signin; si != nil {
|
||||||
|
s.SigningIn, s.Code, s.Page, s.Error = si.running, si.code, si.page, si.err
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignIn starts signing in to this run's hub; the code to enter shows in the status, and the browser opens the
|
||||||
|
// page.
|
||||||
|
func (m *Manager) SignIn() {
|
||||||
|
hubURL := m.Hub
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
si := &signin{running: true, cancel: cancel}
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.signin != nil && m.signin.running {
|
||||||
|
m.signin.cancel()
|
||||||
|
}
|
||||||
|
m.signin = si
|
||||||
|
m.mu.Unlock()
|
||||||
|
go func() {
|
||||||
|
saved, err := hub.SignIn(ctx, hubURL, func(code, page string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
si.code, si.page = code, page
|
||||||
|
m.mu.Unlock()
|
||||||
|
if m.OpenBrowser != nil {
|
||||||
|
m.OpenBrowser(page)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
err = saved.Save(m.Path)
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
si.running = false
|
||||||
|
if err != nil {
|
||||||
|
si.err = err.Error()
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
m.Log.Printf("hub: signing in: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.Log.Printf("hub: signed in to %s", saved.URL)
|
||||||
|
m.run(saved)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignOut ends this computer's sign-in: the session on the hub, the saved sign-in, and the connection.
|
||||||
|
func (m *Manager) SignOut() {
|
||||||
|
m.mu.Lock()
|
||||||
|
c, stop := m.client, m.stop
|
||||||
|
m.client, m.saved, m.stop, m.username, m.signin, m.badges = nil, nil, nil, "", nil, nil
|
||||||
|
m.mu.Unlock()
|
||||||
|
if c != nil {
|
||||||
|
c.Call(context.Background(), "POST", "/api/auth/sign-out", map[string]any{}, nil)
|
||||||
|
}
|
||||||
|
if stop != nil {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
if err := os.Remove(m.Path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
m.Log.Printf("hub: signing out: %v", err)
|
||||||
|
}
|
||||||
|
m.Log.Printf("hub: signed out")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intercept implements proxy.Interceptor: it answers requests to Host.
|
||||||
|
func (m *Manager) Intercept(x *proxy.Exchange) error {
|
||||||
|
if !strings.EqualFold(x.URL.Hostname(), Host) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
method := x.Request.Method
|
||||||
|
switch {
|
||||||
|
case x.URL.Path == "/hub" && method == "GET":
|
||||||
|
case x.URL.Path == "/hub/signin" && method == "POST":
|
||||||
|
m.SignIn()
|
||||||
|
case x.URL.Path == "/hub/server" && method == "POST":
|
||||||
|
form, _ := url.ParseQuery(string(x.Body))
|
||||||
|
hub := form.Get("hub")
|
||||||
|
if hub != "" {
|
||||||
|
var err error
|
||||||
|
if hub, err = CheckHub(hub); err != nil {
|
||||||
|
b, _ := json.Marshal(map[string]string{"error": err.Error()})
|
||||||
|
x.Response = proxy.LocalResponse(400, "application/json", b)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hub == m.Default {
|
||||||
|
hub = "" // the default stays the default, should it change
|
||||||
|
}
|
||||||
|
if err := SaveChoice(m.ChoicePath, hub); err != nil {
|
||||||
|
m.Log.Printf("hub: saving the choice: %v", err)
|
||||||
|
}
|
||||||
|
case x.URL.Path == "/hub/signout" && method == "POST":
|
||||||
|
m.SignOut()
|
||||||
|
case x.URL.Path == "/badges" && method == "POST":
|
||||||
|
var req struct {
|
||||||
|
IDs []int64 `json:"imvuIds"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(x.Body, &req) != nil || len(req.IDs) > 1000 {
|
||||||
|
x.Response = proxy.LocalResponse(400, "application/json", []byte(`{"error":"imvuIds: up to 1000 IMVU account IDs"}`))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
badges := m.Badges(ctx, req.IDs)
|
||||||
|
if badges == nil {
|
||||||
|
badges = []int64{}
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(map[string]any{"badges": badges})
|
||||||
|
x.Response = proxy.LocalResponse(200, "application/json", b)
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
x.Response = proxy.LocalResponse(404, "application/json", []byte(`{"error":"not found"}`))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(m.status())
|
||||||
|
x.Response = proxy.LocalResponse(200, "application/json", b)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package control
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
var badgeAsks atomic.Int32
|
||||||
|
|
||||||
|
// fakeHub does device sign-in (approved on the second poll), /me and sign-out.
|
||||||
|
func fakeHub(t *testing.T, signedOut *atomic.Bool) *httptest.Server {
|
||||||
|
var polls atomic.Int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/auth/device/code":
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"device_code": "dev", "user_code": "ABCD1234",
|
||||||
|
"verification_uri_complete": "http://hub.test/device?code=ABCD1234", "interval": 1, "expires_in": 60})
|
||||||
|
case "/api/auth/device/token":
|
||||||
|
if polls.Add(1) < 2 {
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "authorization_pending"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"access_token": "session-token"})
|
||||||
|
case "/api/v1/me":
|
||||||
|
if r.Header.Get("Authorization") != "Bearer session-token" {
|
||||||
|
w.WriteHeader(401)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
io.WriteString(w, `{"person": {"username": "erin"}}`)
|
||||||
|
case "/api/v1/badges":
|
||||||
|
var req struct{ ImvuIds []int64 }
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
badgeAsks.Add(1)
|
||||||
|
var even []int64
|
||||||
|
for _, id := range req.ImvuIds {
|
||||||
|
if id%2 == 0 {
|
||||||
|
even = append(even, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"badges": even})
|
||||||
|
case "/api/auth/sign-out":
|
||||||
|
signedOut.Store(r.Header.Get("Authorization") == "Bearer session-token")
|
||||||
|
io.WriteString(w, `{"success": true}`)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(404) // the realtime connection: not needed here
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func ask(t *testing.T, m *Manager, method, path, body string) status {
|
||||||
|
t.Helper()
|
||||||
|
u, _ := url.Parse("http://" + Host + path)
|
||||||
|
req, _ := http.NewRequest(method, u.String(), strings.NewReader(body))
|
||||||
|
x := &proxy.Exchange{URL: u, Request: req, Body: []byte(body)}
|
||||||
|
if err := m.Intercept(x); err != nil || x.Response == nil {
|
||||||
|
t.Fatalf("%s %s: %v %v", method, path, err, x.Response)
|
||||||
|
}
|
||||||
|
var s status
|
||||||
|
b, _ := io.ReadAll(x.Response.Body)
|
||||||
|
if err := json.Unmarshal(b, &s); err != nil {
|
||||||
|
t.Fatalf("%s: %s", path, b)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitFor(t *testing.T, ok func() bool) {
|
||||||
|
t.Helper()
|
||||||
|
for i := 0; i < 100 && !ok(); i++ {
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if !ok() {
|
||||||
|
t.Fatal("timed out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignInAndOut(t *testing.T) {
|
||||||
|
var signedOut atomic.Bool
|
||||||
|
srv := fakeHub(t, &signedOut)
|
||||||
|
path := filepath.Join(t.TempDir(), "hub.json")
|
||||||
|
var opened []string
|
||||||
|
attached := 0
|
||||||
|
m := &Manager{Path: path, Hub: srv.URL, Default: srv.URL, Log: log.New(io.Discard, "", 0),
|
||||||
|
OpenBrowser: func(p string) { opened = append(opened, p) }, Attach: func(*hub.Client) { attached++ }}
|
||||||
|
m.Start()
|
||||||
|
if s := ask(t, m, "GET", "/hub", ""); s.SignedIn || s.Hub != srv.URL {
|
||||||
|
t.Fatalf("status %+v", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signing in: the code shows, the browser opens the page, and once approved the proxy is connected.
|
||||||
|
ask(t, m, "POST", "/hub/signin", "")
|
||||||
|
waitFor(t, func() bool { return ask(t, m, "GET", "/hub", "").Code == "ABCD1234" })
|
||||||
|
waitFor(t, func() bool { s := ask(t, m, "GET", "/hub", ""); return s.SignedIn && s.Username == "erin" })
|
||||||
|
if len(opened) != 1 || attached != 1 {
|
||||||
|
t.Fatalf("opened %v, attached %d", opened, attached)
|
||||||
|
}
|
||||||
|
if saved, err := hub.Load(path); err != nil || saved.Token != "session-token" {
|
||||||
|
t.Fatalf("saved %+v %v", saved, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signing out ends the session on the hub and forgets the sign-in.
|
||||||
|
if s := ask(t, m, "POST", "/hub/signout", ""); s.SignedIn {
|
||||||
|
t.Fatalf("still signed in: %+v", s)
|
||||||
|
}
|
||||||
|
if !signedOut.Load() {
|
||||||
|
t.Fatal("the hub wasn't told")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("hub.json: %v", err)
|
||||||
|
}
|
||||||
|
// Other requests to the proxy's address are refused.
|
||||||
|
u, _ := url.Parse("http://" + Host + "/other")
|
||||||
|
req, _ := http.NewRequest("POST", u.String(), nil)
|
||||||
|
x := &proxy.Exchange{URL: u, Request: req}
|
||||||
|
m.Intercept(x)
|
||||||
|
if x.Response.StatusCode != 404 {
|
||||||
|
t.Fatalf("other path: %d", x.Response.StatusCode)
|
||||||
|
}
|
||||||
|
// Requests to other hosts are left alone.
|
||||||
|
u, _ = url.Parse("http://api.imvu.com/hub")
|
||||||
|
x = &proxy.Exchange{URL: u, Request: req}
|
||||||
|
m.Intercept(x)
|
||||||
|
if x.Response != nil {
|
||||||
|
t.Fatal("answered another host")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBadges(t *testing.T) {
|
||||||
|
var signedOut atomic.Bool
|
||||||
|
srv := fakeHub(t, &signedOut)
|
||||||
|
m := &Manager{Path: filepath.Join(t.TempDir(), "hub.json"), Hub: srv.URL, Log: log.New(io.Discard, "", 0)}
|
||||||
|
badges := func(body string) string {
|
||||||
|
t.Helper()
|
||||||
|
u, _ := url.Parse("http://" + Host + "/badges")
|
||||||
|
req, _ := http.NewRequest("POST", u.String(), strings.NewReader(body))
|
||||||
|
x := &proxy.Exchange{URL: u, Request: req, Body: []byte(body)}
|
||||||
|
m.Intercept(x)
|
||||||
|
b, _ := io.ReadAll(x.Response.Body)
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
// Signed out: nobody.
|
||||||
|
if got := badges(`{"imvuIds": [2, 3]}`); got != `{"badges":[]}` {
|
||||||
|
t.Fatalf("signed out: %s", got)
|
||||||
|
}
|
||||||
|
saved := &hub.Saved{URL: srv.URL, Token: "session-token"}
|
||||||
|
m.run(saved)
|
||||||
|
badgeAsks.Store(0)
|
||||||
|
if got := badges(`{"imvuIds": [2, 3, 4, 3]}`); got != `{"badges":[2,4]}` {
|
||||||
|
t.Fatalf("badges %s", got)
|
||||||
|
}
|
||||||
|
// Answers are remembered: only new IDs are asked about.
|
||||||
|
if got := badges(`{"imvuIds": [4, 3, 6]}`); got != `{"badges":[4,6]}` || badgeAsks.Load() != 2 {
|
||||||
|
t.Fatalf("badges %s after %d asks", got, badgeAsks.Load())
|
||||||
|
}
|
||||||
|
if got := badges(`{"imvuIds": "x"}`); !strings.Contains(got, "error") {
|
||||||
|
t.Fatalf("bad request: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package control
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// T5DE's hubs.
|
||||||
|
const (
|
||||||
|
Production = "https://hub.t5de.dev"
|
||||||
|
Staging = "https://hub.staging.t5de.dev"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChannelDefault is the hub an install uses unless the person chooses one: nightlies and builds outside an
|
||||||
|
// install (channel "nightly", "dev") use staging, releases T5DE's hub.
|
||||||
|
func ChannelDefault(channel string) string {
|
||||||
|
if channel == "nightly" || channel == "dev" {
|
||||||
|
return Staging
|
||||||
|
}
|
||||||
|
return Production
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server is the hub for this run: env ($T5DE_HUB) if set, else the person's choice (the client's Settings, kept
|
||||||
|
// in choicePath), else the channel's.
|
||||||
|
func Server(choicePath, channel, env string) string {
|
||||||
|
if env != "" {
|
||||||
|
return strings.TrimSuffix(env, "/")
|
||||||
|
}
|
||||||
|
if c := LoadChoice(choicePath); c != "" {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return ChannelDefault(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadChoice is the hub the person chose, "" for the channel's.
|
||||||
|
func LoadChoice(path string) string {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var c struct {
|
||||||
|
Hub string `json:"hub"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(b, &c) != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
hub, err := CheckHub(c.Hub)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return hub
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveChoice remembers the hub the person chose ("" for the channel's).
|
||||||
|
func SaveChoice(path, hub string) error {
|
||||||
|
if hub == "" {
|
||||||
|
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(map[string]string{"hub": hub})
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckHub is a hub's address without a trailing slash, if it's https (or this computer's, for development).
|
||||||
|
func CheckHub(s string) (string, error) {
|
||||||
|
s = strings.TrimSuffix(strings.TrimSpace(s), "/")
|
||||||
|
u, err := url.Parse(s)
|
||||||
|
if err != nil || u.Host == "" || u.RawQuery != "" || u.Fragment != "" {
|
||||||
|
return "", fmt.Errorf("not a hub address: %q", s)
|
||||||
|
}
|
||||||
|
local := u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"
|
||||||
|
if u.Scheme != "https" && !(u.Scheme == "http" && local) {
|
||||||
|
return "", errors.New("the hub needs an https address")
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StateDir is where the proxy keeps what belongs to a hub (its sign-in, product IDs, products): the data folder
|
||||||
|
// itself for T5DE's hub, hubs/<name> in it for any other, so a hub never gets another's sign-in and product IDs
|
||||||
|
// from two hubs never clash.
|
||||||
|
func StateDir(data, hub string) string {
|
||||||
|
if hub == Production {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
u, err := url.Parse(hub)
|
||||||
|
name := hub
|
||||||
|
if err == nil {
|
||||||
|
name = u.Host + u.Path
|
||||||
|
}
|
||||||
|
name = strings.Map(func(r rune) rune {
|
||||||
|
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '.' || r == '-' {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
if r >= 'A' && r <= 'Z' {
|
||||||
|
return r + 'a' - 'A'
|
||||||
|
}
|
||||||
|
return '_'
|
||||||
|
}, name)
|
||||||
|
return filepath.Join(data, "hubs", name)
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package control
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServer(t *testing.T) {
|
||||||
|
choice := filepath.Join(t.TempDir(), "server.json")
|
||||||
|
for channel, want := range map[string]string{"release": Production, "": Production, "nightly": Staging, "dev": Staging} {
|
||||||
|
if got := Server(choice, channel, ""); got != want {
|
||||||
|
t.Errorf("channel %q: %s", channel, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := SaveChoice(choice, "https://hub.example.org"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := Server(choice, "nightly", ""); got != "https://hub.example.org" {
|
||||||
|
t.Errorf("chosen: %s", got)
|
||||||
|
}
|
||||||
|
if got := Server(choice, "nightly", "http://localhost:4101/"); got != "http://localhost:4101" {
|
||||||
|
t.Errorf("$T5DE_HUB: %s", got)
|
||||||
|
}
|
||||||
|
if err := SaveChoice(choice, ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := Server(choice, "release", ""); got != Production {
|
||||||
|
t.Errorf("default again: %s", got)
|
||||||
|
}
|
||||||
|
// A choice that isn't a hub address is ignored.
|
||||||
|
os.WriteFile(choice, []byte(`{"hub": "http://evil.test"}`), 0o600)
|
||||||
|
if got := Server(choice, "release", ""); got != Production {
|
||||||
|
t.Errorf("bad choice: %s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := `C:\data`
|
||||||
|
if got := StateDir(data, Production); got != data {
|
||||||
|
t.Errorf("production state %s", got)
|
||||||
|
}
|
||||||
|
if got := StateDir(data, Staging); got != filepath.Join(data, "hubs", "hub.staging.t5de.dev") {
|
||||||
|
t.Errorf("staging state %s", got)
|
||||||
|
}
|
||||||
|
if got := StateDir(data, "http://LocalHost:4101/x"); got != filepath.Join(data, "hubs", "localhost_4101_x") {
|
||||||
|
t.Errorf("local state %s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bad := range []string{"http://evil.test", "ftp://x", "hub.t5de.dev", "https://", "https://x/?a=1"} {
|
||||||
|
if _, err := CheckHub(bad); err == nil {
|
||||||
|
t.Errorf("accepted %q", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChoosingServer(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := &Manager{Path: filepath.Join(dir, "hub.json"), Hub: Staging, Default: Staging, ChoicePath: filepath.Join(dir, "server.json"),
|
||||||
|
Log: log.New(io.Discard, "", 0)}
|
||||||
|
if s := ask(t, m, "GET", "/hub", ""); s.Hub != Staging || s.Default != Staging || s.Choice != "" || s.Restart {
|
||||||
|
t.Fatalf("status %+v", s)
|
||||||
|
}
|
||||||
|
if s := ask(t, m, "POST", "/hub/server", "hub="+url.QueryEscape(Production+"/")); s.Choice != Production || !s.Restart || s.Hub != Staging {
|
||||||
|
t.Fatalf("chose production: %+v", s)
|
||||||
|
}
|
||||||
|
// Choosing the default again forgets the choice.
|
||||||
|
if s := ask(t, m, "POST", "/hub/server", "hub="+url.QueryEscape(Staging)); s.Choice != "" || s.Restart {
|
||||||
|
t.Fatalf("back to the default: %+v", s)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(m.ChoicePath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("server.json kept: %v", err)
|
||||||
|
}
|
||||||
|
u, _ := url.Parse("http://" + Host + "/hub/server")
|
||||||
|
body := "hub=http://evil.test"
|
||||||
|
req, _ := http.NewRequest("POST", u.String(), strings.NewReader(body))
|
||||||
|
x := &proxy.Exchange{URL: u, Request: req, Body: []byte(body)}
|
||||||
|
m.Intercept(x)
|
||||||
|
if x.Response.StatusCode != 400 {
|
||||||
|
t.Fatalf("plain http hub: %d", x.Response.StatusCode)
|
||||||
|
}
|
||||||
|
// Set by $T5DE_HUB: never a restart to suggest.
|
||||||
|
m.Fixed = true
|
||||||
|
ask(t, m, "POST", "/hub/server", "hub="+url.QueryEscape(Production))
|
||||||
|
if s := ask(t, m, "GET", "/hub", ""); s.Restart || !s.Fixed {
|
||||||
|
t.Fatalf("fixed: %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignInForAnotherHub(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "hub.json")
|
||||||
|
if err := (&hub.Saved{URL: "http://localhost:4101", Token: "old"}).Save(path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
attached := 0
|
||||||
|
m := &Manager{Path: path, Hub: Production, Log: log.New(io.Discard, "", 0), Attach: func(*hub.Client) { attached++ }}
|
||||||
|
m.Start()
|
||||||
|
if s := ask(t, m, "GET", "/hub", ""); s.SignedIn || attached != 0 {
|
||||||
|
t.Fatalf("used another hub's sign-in: %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
// Package fetch gets T5 products from the hub's catalogs (t5de-hub's docs/products.md): the hub says which revision
|
||||||
|
// to load and which catalogs host it; the files come from a catalog, each checked against the hub's sha256, and the
|
||||||
|
// product is written as a pack in the cache folder, where the products package serves it like any other pack.
|
||||||
|
package fetch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hub is what the fetcher asks the hub.
|
||||||
|
type Hub interface {
|
||||||
|
ProductFiles(ctx context.Context, id string) (*hub.Files, error)
|
||||||
|
AccessToken(ctx context.Context) (string, error)
|
||||||
|
Library(ctx context.Context) ([]hub.LibraryEntry, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsHubID reports whether id is a hub product ID (10 of a-z2-7), as opposed to a local pack's.
|
||||||
|
var IsHubID = regexp.MustCompile(`^[a-z2-7]{10}$`).MatchString
|
||||||
|
|
||||||
|
// Fetcher keeps hub products in a cache folder, one pack folder per product.
|
||||||
|
type Fetcher struct {
|
||||||
|
hub Hub
|
||||||
|
dir string
|
||||||
|
logf func(format string, args ...any)
|
||||||
|
http *http.Client
|
||||||
|
|
||||||
|
// OnReady, if set, is called when a product was written (or updated) in the cache.
|
||||||
|
OnReady func(id string)
|
||||||
|
// OnLibrary, if set, is called after the library was read and its products fetched.
|
||||||
|
OnLibrary func()
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
library map[string]bool // the person's library, as last read
|
||||||
|
entries []hub.LibraryEntry
|
||||||
|
inflight map[string]bool
|
||||||
|
failed map[string]time.Time // products that failed lately (not retried for a while)
|
||||||
|
access string
|
||||||
|
accessAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// New keeps products in dir.
|
||||||
|
func New(h Hub, dir string, logf func(format string, args ...any)) *Fetcher {
|
||||||
|
if logf == nil {
|
||||||
|
logf = func(string, ...any) {}
|
||||||
|
}
|
||||||
|
return &Fetcher{hub: h, dir: dir, logf: logf, http: &http.Client{Timeout: 2 * time.Minute},
|
||||||
|
inflight: map[string]bool{}, failed: map[string]time.Time{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dir is the cache folder (a pack root).
|
||||||
|
func (f *Fetcher) Dir() string { return f.dir }
|
||||||
|
|
||||||
|
// SyncLibrary makes sure every product in the person's library is in the cache, at the revision they use.
|
||||||
|
func (f *Fetcher) SyncLibrary(ctx context.Context) {
|
||||||
|
lib, err := f.hub.Library(ctx)
|
||||||
|
if err != nil {
|
||||||
|
f.logf("fetch: reading the library: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := map[string]bool{}
|
||||||
|
for _, e := range lib {
|
||||||
|
ids[e.Product.ID] = true
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
f.library, f.entries = ids, lib
|
||||||
|
f.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
if f.OnLibrary != nil {
|
||||||
|
f.OnLibrary()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for _, e := range lib {
|
||||||
|
if err := f.Ensure(ctx, e.Product.ID); err != nil {
|
||||||
|
f.logf("fetch: %s (%s): %v", e.Product.ID, e.Product.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Want gets a product in the background, once at a time, and not again soon after a failure.
|
||||||
|
func (f *Fetcher) Want(id string) {
|
||||||
|
if !IsHubID(id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
if f.inflight[id] || time.Since(f.failed[id]) < 5*time.Minute {
|
||||||
|
f.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.inflight[id] = true
|
||||||
|
f.mu.Unlock()
|
||||||
|
go func() {
|
||||||
|
err := f.Ensure(context.Background(), id)
|
||||||
|
f.mu.Lock()
|
||||||
|
delete(f.inflight, id)
|
||||||
|
if err != nil {
|
||||||
|
f.failed[id] = time.Now()
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
f.logf("fetch: %s: %v", id, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// manifest is the pack manifest written for a product (t5de-api's pack format 1).
|
||||||
|
type manifest struct {
|
||||||
|
Format int `json:"format"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Revision int `json:"revision"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Creator string `json:"creator,omitempty"`
|
||||||
|
Rating string `json:"rating"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure puts the product's current revision in the cache, unless it's there already.
|
||||||
|
func (f *Fetcher) Ensure(ctx context.Context, id string) error {
|
||||||
|
if !IsHubID(id) {
|
||||||
|
return fmt.Errorf("not a hub product ID: %q", id)
|
||||||
|
}
|
||||||
|
info, err := f.hub.ProductFiles(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
final := filepath.Join(f.dir, id)
|
||||||
|
if b, err := os.ReadFile(filepath.Join(final, "t5de-product.json")); err == nil {
|
||||||
|
var m manifest
|
||||||
|
if json.Unmarshal(b, &m) == nil && m.Revision == info.Revision {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(info.Catalogs) == 0 {
|
||||||
|
return errors.New("no catalog hosts it")
|
||||||
|
}
|
||||||
|
for _, fe := range info.Files {
|
||||||
|
if fe.Name == "" || fe.Name == "." || fe.Name == ".." || filepath.Base(fe.Name) != fe.Name || strings.ContainsAny(fe.Name, `/\:`) {
|
||||||
|
return fmt.Errorf("bad file name %q", fe.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
token := ""
|
||||||
|
if info.Rating == "adult" {
|
||||||
|
if token, err = f.accessToken(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, fe := range info.Files {
|
||||||
|
if err := f.blob(ctx, info.Catalogs, fe, token); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", fe.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pack, in a temporary folder, then in place.
|
||||||
|
tmp, err := os.MkdirTemp(f.dir, "."+id+"-")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmp)
|
||||||
|
if err := os.Mkdir(filepath.Join(tmp, "files"), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, fe := range info.Files {
|
||||||
|
if err := copyFile(f.blobPath(fe.SHA256), filepath.Join(tmp, "files", fe.Name)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m := manifest{Format: 1, ID: id, Revision: info.Revision, Name: clip(info.Name, "Untitled"), Creator: clip(info.Creator, ""), Rating: info.Rating}
|
||||||
|
b, _ := json.MarshalIndent(m, "", " ")
|
||||||
|
if err := os.WriteFile(filepath.Join(tmp, "t5de-product.json"), b, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
old := final + ".old"
|
||||||
|
os.RemoveAll(old)
|
||||||
|
if _, err := os.Stat(final); err == nil {
|
||||||
|
if err := os.Rename(final, old); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, final); err != nil {
|
||||||
|
os.Rename(old, final)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
os.RemoveAll(old)
|
||||||
|
f.logf("fetch: %s revision %d (%s) is ready", id, info.Revision, m.Name)
|
||||||
|
if f.OnReady != nil {
|
||||||
|
f.OnReady(id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// clip makes a name fit the pack format (1-64 characters, no control characters).
|
||||||
|
func clip(s, empty string) string {
|
||||||
|
s = strings.Map(func(r rune) rune {
|
||||||
|
if r < 0x20 || r == 0x7f {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, strings.TrimSpace(s))
|
||||||
|
for utf8.RuneCountInString(s) > 64 {
|
||||||
|
_, n := utf8.DecodeLastRuneInString(s)
|
||||||
|
s = s[:len(s)-n]
|
||||||
|
}
|
||||||
|
if s == "" {
|
||||||
|
return empty
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) blobPath(sum string) string { return filepath.Join(f.dir, ".blobs", sum) }
|
||||||
|
|
||||||
|
// blob gets a file into the blob cache (by sha256), from the first catalog that has it.
|
||||||
|
func (f *Fetcher) blob(ctx context.Context, catalogs []string, fe hub.FileEntry, token string) error {
|
||||||
|
path := f.blobPath(fe.SHA256)
|
||||||
|
if st, err := os.Stat(path); err == nil && st.Size() == fe.Size {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var last error
|
||||||
|
for _, c := range catalogs {
|
||||||
|
if last = f.download(ctx, strings.TrimSuffix(c, "/")+"/api/v1/files/"+fe.SHA256, fe, token, path); last == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) download(ctx context.Context, url string, fe hub.FileEntry, token, path string) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", hub.UserAgent)
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
resp, err := f.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("%s answered %d", url, resp.StatusCode)
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp(filepath.Dir(path), ".dl-")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(tmp.Name())
|
||||||
|
h := sha256.New()
|
||||||
|
n, err := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(resp.Body, fe.Size+1))
|
||||||
|
tmp.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n != fe.Size || hex.EncodeToString(h.Sum(nil)) != fe.SHA256 {
|
||||||
|
return fmt.Errorf("%s sent a different file than the hub listed", url)
|
||||||
|
}
|
||||||
|
return os.Rename(tmp.Name(), path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) accessToken(ctx context.Context) (string, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
tok, at := f.access, f.accessAt
|
||||||
|
f.mu.Unlock()
|
||||||
|
if tok != "" && time.Since(at) < 50*time.Minute {
|
||||||
|
return tok, nil
|
||||||
|
}
|
||||||
|
tok, err := f.hub.AccessToken(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
f.access, f.accessAt = tok, time.Now()
|
||||||
|
f.mu.Unlock()
|
||||||
|
return tok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
out, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
out.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return out.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owned reports whether a product is in the person's library (as last read).
|
||||||
|
func (f *Fetcher) Owned(id string) bool {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.library[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Library is the person's library, as last read.
|
||||||
|
func (f *Fetcher) Library() []hub.LibraryEntry {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return append([]hub.LibraryEntry(nil), f.entries...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package fetch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-api/pack"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||||
|
)
|
||||||
|
|
||||||
|
const id = "abcdefgh23"
|
||||||
|
|
||||||
|
func indexXML(rating string) []byte {
|
||||||
|
return []byte(`<?xml version="1.0" encoding="UTF-8" ?><Template><ProductRating>` + rating +
|
||||||
|
`</ProductRating><__DATAIMPORT>product://500/index.xml</__DATAIMPORT><ProductName>x</ProductName></Template>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func entry(name string, data []byte) hub.FileEntry {
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
return hub.FileEntry{Name: name, SHA256: hex.EncodeToString(sum[:]), Size: int64(len(data))}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeHub answers for one product.
|
||||||
|
type fakeHub struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
files hub.Files
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *fakeHub) ProductFiles(ctx context.Context, pid string) (*hub.Files, error) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
f := h.files
|
||||||
|
return &f, nil
|
||||||
|
}
|
||||||
|
func (h *fakeHub) AccessToken(ctx context.Context) (string, error) { return "adult-token", nil }
|
||||||
|
func (h *fakeHub) Library(ctx context.Context) ([]hub.LibraryEntry, error) {
|
||||||
|
var e hub.LibraryEntry
|
||||||
|
e.Product.ID = id
|
||||||
|
return []hub.LibraryEntry{e}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// catalog serves blobs by sha256; adult ones need the token.
|
||||||
|
func catalog(t *testing.T, blobs map[string][]byte, adult map[string]bool, served *int) *httptest.Server {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sum := strings.TrimPrefix(r.URL.Path, "/api/v1/files/")
|
||||||
|
b, ok := blobs[sum]
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if adult[sum] && r.Header.Get("Authorization") != "Bearer adult-token" {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
*served++
|
||||||
|
w.Write(b)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureWritesAPack(t *testing.T) {
|
||||||
|
index, tex := indexXML("GA"), []byte("texture one")
|
||||||
|
tex2 := []byte("texture two")
|
||||||
|
blobs := map[string][]byte{}
|
||||||
|
for _, b := range [][]byte{index, tex, tex2} {
|
||||||
|
blobs[entry("", b).SHA256] = b
|
||||||
|
}
|
||||||
|
served := 0
|
||||||
|
cat := catalog(t, blobs, nil, &served)
|
||||||
|
h := &fakeHub{files: hub.Files{Product: id, Revision: 1, Rating: "general", Name: "Blue Coat", Creator: "maker",
|
||||||
|
Files: []hub.FileEntry{entry("index.xml", index), entry("tex.png", tex)}, Catalogs: []string{"http://127.0.0.1:1", cat.URL}}}
|
||||||
|
dir := t.TempDir()
|
||||||
|
f := New(h, dir, t.Logf)
|
||||||
|
var ready []string
|
||||||
|
f.OnReady = func(id string) { ready = append(ready, id) }
|
||||||
|
|
||||||
|
f.SyncLibrary(context.Background())
|
||||||
|
p, err := pack.Load(filepath.Join(dir, id))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p.Manifest.Name != "Blue Coat" || p.Manifest.Revision != 1 || len(p.Files) != 2 || len(ready) != 1 {
|
||||||
|
t.Fatalf("pack %+v, ready %v", p.Manifest, ready)
|
||||||
|
}
|
||||||
|
// Already there: nothing fetched again.
|
||||||
|
if err := f.Ensure(context.Background(), id); err != nil || served != 2 || len(ready) != 1 {
|
||||||
|
t.Fatalf("again: %v, served %d", err, served)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new revision: only the changed file is downloaded; the pack is replaced.
|
||||||
|
h.files.Revision, h.files.Files = 2, []hub.FileEntry{entry("index.xml", index), entry("tex.png", tex2)}
|
||||||
|
if err := f.Ensure(context.Background(), id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if b, _ := os.ReadFile(filepath.Join(dir, id, "files", "tex.png")); string(b) != "texture two" || served != 3 {
|
||||||
|
t.Fatalf("tex %q, served %d", b, served)
|
||||||
|
}
|
||||||
|
entries, _ := os.ReadDir(dir)
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Name() != id && e.Name() != ".blobs" {
|
||||||
|
t.Fatalf("left behind %s", e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureChecksFilesAndAdultTokens(t *testing.T) {
|
||||||
|
index := indexXML("AP")
|
||||||
|
blobs := map[string][]byte{entry("", index).SHA256: index}
|
||||||
|
adult := map[string]bool{entry("", index).SHA256: true}
|
||||||
|
served := 0
|
||||||
|
cat := catalog(t, blobs, adult, &served)
|
||||||
|
h := &fakeHub{files: hub.Files{Product: id, Revision: 1, Rating: "adult", Name: "Lace",
|
||||||
|
Files: []hub.FileEntry{entry("index.xml", index)}, Catalogs: []string{cat.URL}}}
|
||||||
|
f := New(h, t.TempDir(), t.Logf)
|
||||||
|
if err := f.Ensure(context.Background(), id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A catalog sending something else than the hub listed is refused.
|
||||||
|
h.files.Revision = 2
|
||||||
|
h.files.Files = []hub.FileEntry{{Name: "index.xml", SHA256: strings.Repeat("0", 64), Size: int64(len(index))}}
|
||||||
|
blobs[strings.Repeat("0", 64)] = []byte(strings.Repeat("x", len(index)))
|
||||||
|
if err := f.Ensure(context.Background(), id); err == nil || !strings.Contains(err.Error(), "different file") {
|
||||||
|
t.Fatalf("got %v", err)
|
||||||
|
}
|
||||||
|
// File names that would leave the pack's folder are refused.
|
||||||
|
h.files.Files = []hub.FileEntry{{Name: `..\evil`, SHA256: strings.Repeat("0", 64), Size: 1}}
|
||||||
|
if err := f.Ensure(context.Background(), id); err == nil || !strings.Contains(err.Error(), "bad file name") {
|
||||||
|
t.Fatalf("got %v", err)
|
||||||
|
}
|
||||||
|
if err := f.Ensure(context.Background(), "../etc"); err == nil {
|
||||||
|
t.Fatal("bad ID accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,443 @@
|
|||||||
|
package hosted
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/imq"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/xmlrpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeHub answers joins at once (through rooms.FromHub) and records what it's sent.
|
||||||
|
type fakeHub struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
rooms *Rooms
|
||||||
|
sent []map[string]any
|
||||||
|
scene []any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *fakeHub) Send(v any) bool {
|
||||||
|
m := v.(map[string]any)
|
||||||
|
h.mu.Lock()
|
||||||
|
h.sent = append(h.sent, m)
|
||||||
|
h.mu.Unlock()
|
||||||
|
if m["type"] == "room.join" {
|
||||||
|
n := m["room"].(int64)
|
||||||
|
var answer string
|
||||||
|
if n == 9 {
|
||||||
|
answer = `{"type": "room.error", "room": 9, "reason": "full", "message": "This room is full."}`
|
||||||
|
} else {
|
||||||
|
answer = fmt.Sprintf(`{"type": "room.joined", "room": %d, "chat": %d, "sequence": 4,
|
||||||
|
"participants": [{"imvu_id": 7, "seat": 1}, {"imvu_id": 88, "seat": 2}]}`, n, Base+n)
|
||||||
|
}
|
||||||
|
go h.rooms.FromHub([]byte(answer))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *fakeHub) Call(ctx context.Context, method, path string, body, out any) error {
|
||||||
|
switch {
|
||||||
|
case method == "GET" && path == "/api/v1/hosted-rooms/3":
|
||||||
|
return json.Unmarshal([]byte(`{"room": {"n": 3, "name": "Lounge <b>", "ownerImvuId": 88, "ownerName": "Erin",
|
||||||
|
"roomPid": 50168116, "rating": "general", "maxUsers": 10, "occupancy": 2},
|
||||||
|
"scene": {"contents": [[1, 47477426, "37207-46"], [2, "coatcoat22", "37-11"], [3, "notherenn2", "37-12"]], "hash": "h1"}}`), out)
|
||||||
|
case method == "GET" && strings.HasPrefix(path, "/api/v1/decorations?instances="):
|
||||||
|
return json.Unmarshal([]byte(`{"rooms": [{"instance": "1-644", "contents": [[1, "coatcoat22", "x"]]}, {"instance": "2-17", "contents": []}]}`), out)
|
||||||
|
case method == "GET" && path == "/api/v1/hosted-rooms/3/participants":
|
||||||
|
return json.Unmarshal([]byte(`{"participants": [{"imvu_id": 88, "seat": 1, "name": "Erin<"}]}`), out)
|
||||||
|
case method == "GET" && path == "/api/v1/hosted-rooms":
|
||||||
|
return json.Unmarshal([]byte(`{"rooms": [{"n": 3, "name": "Lounge", "ownerImvuId": 88, "ownerName": "Erin", "maxUsers": 10, "occupancy": 2}]}`), out)
|
||||||
|
case (method == "PUT" || method == "DELETE") && (path == "/api/v1/hosted-rooms/3/favorite" || path == "/api/v1/hosted-rooms/3/rating"):
|
||||||
|
h.mu.Lock()
|
||||||
|
h.sent = append(h.sent, map[string]any{"type": method + " " + path, "body": body})
|
||||||
|
h.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
case method == "PUT" && path == "/api/v1/hosted-rooms/3/scene":
|
||||||
|
h.mu.Lock()
|
||||||
|
h.scene = append(h.scene, body)
|
||||||
|
h.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("unexpected %s %s", method, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *fakeHub) sentOf(kind string) []map[string]any {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
var out []map[string]any
|
||||||
|
for _, m := range h.sent {
|
||||||
|
if m["type"] == kind {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func setup() (*Rooms, *fakeHub, *[]string) {
|
||||||
|
h := &fakeHub{}
|
||||||
|
var events []string
|
||||||
|
r := &Rooms{Hub: h, User: func() int64 { return 7 },
|
||||||
|
OnJoin: func(chat, room string) { events = append(events, "join "+chat+" "+room) },
|
||||||
|
OnLeave: func(chat string) { events = append(events, "leave "+chat) },
|
||||||
|
LocalProduct: func(id string) (int64, bool) { return 2000000005, id == "coatcoat22" },
|
||||||
|
GlobalProduct: func(local int64) (string, bool) { return "coatcoat22", local == 2000000005 }}
|
||||||
|
h.rooms = r
|
||||||
|
return r, h, &events
|
||||||
|
}
|
||||||
|
|
||||||
|
func xmlrpcCall(t *testing.T, r *Rooms, host, method string, args xmlrpc.Struct) (*xmlrpc.Response, bool) {
|
||||||
|
t.Helper()
|
||||||
|
body := (&xmlrpc.Call{Method: method, Params: []xmlrpc.Value{args}}).Marshal()
|
||||||
|
u, _ := url.Parse("https://" + host + "/api/xmlrpc/" + map[bool]string{true: "chat.php", false: "client.php"}[strings.HasPrefix(method, "chat.")])
|
||||||
|
req, _ := http.NewRequest("POST", u.String(), bytes.NewReader(body))
|
||||||
|
x := &proxy.Exchange{URL: u, Request: req, Body: body}
|
||||||
|
r.Intercept(x)
|
||||||
|
if x.Response == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
b, _ := io.ReadAll(x.Response.Body)
|
||||||
|
resp, err := xmlrpc.ParseResponse(b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v\n%s", method, err, b)
|
||||||
|
}
|
||||||
|
return resp, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func get(v xmlrpc.Value, name string) xmlrpc.Value {
|
||||||
|
s, _ := v.(xmlrpc.Struct)
|
||||||
|
out, _ := s.Get(name)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// frames reads the frames the proxy sent the client.
|
||||||
|
func frames(t *testing.T, b []byte) []imq.Frame {
|
||||||
|
var out []imq.Frame
|
||||||
|
for len(b) > 0 {
|
||||||
|
f, n, err := imq.Next(b)
|
||||||
|
if err != nil || n == 0 {
|
||||||
|
t.Fatalf("frames: %v", err)
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
b = b[n:]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostedRoom(t *testing.T) {
|
||||||
|
r, h, events := setup()
|
||||||
|
|
||||||
|
// IMVU's rooms are left alone.
|
||||||
|
if _, answered := xmlrpcCall(t, r, "chat.imvu.com", "chat.getOrMakeChat", xmlrpc.Struct{{Name: "activity", Value: "publicroom-170879926-5533"}}); answered {
|
||||||
|
t.Fatal("answered an IMVU room")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Joining: the hub lets the client in; it sits in the first free seat.
|
||||||
|
resp, _ := xmlrpcCall(t, r, "chat.imvu.com", "chat.getOrMakeChat", xmlrpc.Struct{{Name: "activity", Value: "publicroom-88-2100000003"}})
|
||||||
|
if get(resp.Params[0], "chatId") != int64(2100000003) || get(resp.Params[0], "seat") != int64(1) || get(resp.Params[0], "lastMessageId") != int64(4) {
|
||||||
|
t.Fatalf("join answer %v", resp.Params[0])
|
||||||
|
}
|
||||||
|
if len(*events) != 1 || (*events)[0] != "join 2100000003 88-2100000003" {
|
||||||
|
t.Fatalf("events %v", *events)
|
||||||
|
}
|
||||||
|
// A full room: the client is told why.
|
||||||
|
if resp, _ := xmlrpcCall(t, r, "chat.imvu.com", "chat.getOrMakeChat", xmlrpc.Struct{{Name: "activity", Value: "publicroom-88-2100000009"}}); get(resp.Params[0], "response") != "declined" || get(resp.Params[0], "explanation") != "This room is full." || get(resp.Params[0], "reason") != int64(5) {
|
||||||
|
t.Fatalf("full room: %+v", resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Others in the room, without the client.
|
||||||
|
resp, _ = xmlrpcCall(t, r, "chat.imvu.com", "chat.getParticipants", xmlrpc.Struct{{Name: "userId", Value: int64(7)}, {Name: "chatId", Value: int64(2100000003)}})
|
||||||
|
others := resp.Params[0].([]xmlrpc.Value)
|
||||||
|
if len(others) != 1 || get(others[0], "userId") != int64(88) {
|
||||||
|
t.Fatalf("participants %v", others)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMQ: the room's queues are answered here; IMVU gets the rest.
|
||||||
|
var toClient bytes.Buffer
|
||||||
|
c := proxy.NewIMQConn(1, &toClient)
|
||||||
|
sub := imq.Subscription{Queues: []string{"/user/7"}, WithResults: []imq.QueueOp{{Queue: "/chat/2100000003", OpID: 5}, {Queue: "/chat/1385863547", OpID: 6}}}
|
||||||
|
rest := r.Route(c, false, sub)
|
||||||
|
if fmt.Sprint(rest.All()) != "[/user/7 /chat/1385863547]" {
|
||||||
|
t.Fatalf("for IMVU: %v", rest.All())
|
||||||
|
}
|
||||||
|
var types []string
|
||||||
|
for _, f := range frames(t, toClient.Bytes()) {
|
||||||
|
types = append(types, imq.TypeName(f.Type))
|
||||||
|
}
|
||||||
|
if got := strings.Join(types, " "); got != "G2cJoinedQueue G2cCreateMount G2cCreateMount G2cCreateMount G2cResult" {
|
||||||
|
t.Fatalf("frames %s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat goes to the hub; sitting down too.
|
||||||
|
if !r.Send("/chat/2100000003", "messages", []byte(`{"message": "*seat 2", "userId": 7}`), []byte(`{"message": "*seat 2", "userId": 7}`), false) || r.Send("/chat/1385863547", "messages", []byte(`{}`), []byte(`{}`), false) {
|
||||||
|
t.Fatal("Send's answer")
|
||||||
|
}
|
||||||
|
r.Send("/chat/2100000003", "messages", []byte(`{"message": "*msg SeatAssignment 0 7 3 0", "userId": 7}`), nil, false)
|
||||||
|
r.Send("/chat/2100000003", "messages", []byte(`{"message": "*msg SeatAssignment 0 7 1 5521", "userId": 7}`), nil, false) // on furniture
|
||||||
|
// The seat chosen on joining (1), "*seat 2", and the room seat 3.
|
||||||
|
if seats, sends := h.sentOf("room.seat"), h.sentOf("room.send"); len(seats) != 3 || seats[0]["seat"] != 1 || seats[1]["seat"] != 2 || seats[2]["seat"] != 3 || len(sends) != 3 {
|
||||||
|
t.Fatalf("seats %v, sends %v", seats, sends)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Others' chat comes back as the gateway would deliver it.
|
||||||
|
toClient.Reset()
|
||||||
|
r.FromHub([]byte(`{"type": "room.message", "room": 3, "from": 88, "mount": "messages", "message": "{\"message\":\"hi\",\"userId\":88}", "sequence": 5}`))
|
||||||
|
fs := frames(t, toClient.Bytes())
|
||||||
|
if len(fs) != 1 {
|
||||||
|
t.Fatalf("%d frames", len(fs))
|
||||||
|
}
|
||||||
|
d, _ := imq.ParseDelivered(fs[0].Data)
|
||||||
|
if d.UserID != "/user/88" || d.Queue != "/chat/2100000003" || d.Sequence != 5 || !strings.Contains(string(d.Message), "hi") {
|
||||||
|
t.Fatalf("delivered %+v", d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The room's card and scene come from the hub; the scene's community products as local IDs (missing ones left out).
|
||||||
|
resp, _ = xmlrpcCall(t, r, "client-dynamic.imvu.com", "test.getRoomCardInfo", xmlrpc.Struct{{Name: "room_instance_id", Value: "88-2100000003"}})
|
||||||
|
if get(resp.Params[0], "name") != "Lounge b" || get(resp.Params[0], "owner_cid") != int64(88) {
|
||||||
|
t.Fatalf("card %v", resp.Params[0])
|
||||||
|
}
|
||||||
|
resp, _ = xmlrpcCall(t, r, "client-dynamic.imvu.com", "test.getRoomState", xmlrpc.Struct{{Name: "room_instance_id", Value: "88-2100000003"}})
|
||||||
|
contents := get(get(resp.Params[0], "room_state"), "room_contents").([]xmlrpc.Value)
|
||||||
|
if len(contents) != 2 || fmt.Sprint(contents[1]) != "[2 2000000005 37-11]" || get(get(get(resp.Params[0], "room_state"), "room_info"), "room_pid") != "50168116" {
|
||||||
|
t.Fatalf("scene %v", resp.Params[0])
|
||||||
|
}
|
||||||
|
// Saving the scene: community products as the hub's IDs.
|
||||||
|
xmlrpcCall(t, r, "client-dynamic.imvu.com", "test.setRoomState", xmlrpc.Struct{{Name: "room_instance_id", Value: "88-2100000003"},
|
||||||
|
{Name: "room_contents", Value: []xmlrpc.Value{[]xmlrpc.Value{int64(1), int64(47477426), "37-1"}, []xmlrpc.Value{int64(2), int64(2000000005), "37-2"}}}})
|
||||||
|
if b, _ := json.Marshal(h.scene); string(b) != `[{"contents":[[1,47477426,"37-1"],[2,"coatcoat22","37-2"]]}]` {
|
||||||
|
t.Fatalf("saved %s", b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The participant post for a hosted chat is answered here.
|
||||||
|
u, _ := url.Parse("https://api.imvu.com/chat/chat-2100000003/participants/user-7")
|
||||||
|
req, _ := http.NewRequest("POST", u.String(), nil)
|
||||||
|
x := &proxy.Exchange{URL: u, Request: req}
|
||||||
|
r.Intercept(x)
|
||||||
|
if x.Response == nil || x.Response.StatusCode != 200 {
|
||||||
|
t.Fatal("participant post not answered")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything else naming a hosted room is answered here, not sent to IMVU.
|
||||||
|
for _, raw := range []string{"https://api.imvu.com/room/room-88-2100000003", "https://client-dynamic.imvu.com/api/rooms/room_rating.php?room_instance_id=88-2100000003",
|
||||||
|
"https://client-dynamic.imvu.com/api/whatever.php?chat=2100000003"} {
|
||||||
|
u, _ := url.Parse(raw)
|
||||||
|
req, _ := http.NewRequest("GET", u.String(), nil)
|
||||||
|
x := &proxy.Exchange{URL: u, Request: req}
|
||||||
|
r.Intercept(x)
|
||||||
|
if x.Response == nil {
|
||||||
|
t.Errorf("%s went to IMVU", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaving.
|
||||||
|
xmlrpcCall(t, r, "chat.imvu.com", "chat.terminateChat", xmlrpc.Struct{{Name: "userId", Value: int64(7)}, {Name: "chatId", Value: int64(2100000003)}})
|
||||||
|
if len(h.sentOf("room.leave")) != 1 || (*events)[len(*events)-1] != "leave 2100000003" {
|
||||||
|
t.Fatalf("leave: %v %v", h.sentOf("room.leave"), *events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoomList(t *testing.T) {
|
||||||
|
r, _, _ := setup()
|
||||||
|
u, _ := url.Parse("https://client-dynamic.imvu.com/api/rooms/rooms_list_paginated.php?page=1")
|
||||||
|
req, _ := http.NewRequest("GET", u.String(), nil)
|
||||||
|
x := &proxy.Exchange{URL: u, Request: req}
|
||||||
|
r.Intercept(x)
|
||||||
|
body := []byte(`{"customers_room_id": ["644", "17"], "customers_id": ["1", "2"], "num_participants": [4, 1], "name": ["A", "B"], "participants": [[], []], "is_ap": ["0", "0"], "odd": [{"x": 1}, {}], "number_of_rooms": 2}`)
|
||||||
|
out, err := x.ModifyResponse(&http.Response{StatusCode: 200}, body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var l map[string]any
|
||||||
|
json.Unmarshal(out, &l)
|
||||||
|
if fmt.Sprint(l["customers_room_id"]) != "[644 17 2100000003]" || fmt.Sprint(l["customers_id"]) != "[1 2 88]" ||
|
||||||
|
fmt.Sprint(l["num_participants"]) != "[4 1 2]" || fmt.Sprint(l["odd"]) != "[map[x:1] map[] map[]]" || l["number_of_rooms"] != float64(2) ||
|
||||||
|
fmt.Sprint(l["t5de_t5"]) != "[1 0 1]" { // decorated, plain, hosted
|
||||||
|
t.Fatalf("list %s", out)
|
||||||
|
}
|
||||||
|
// Other pages get no hosted rooms, but their decorated rooms are marked.
|
||||||
|
u, _ = url.Parse("https://client-dynamic.imvu.com/api/rooms/rooms_list_paginated.php?page=2")
|
||||||
|
x = &proxy.Exchange{URL: u, Request: req}
|
||||||
|
r.Intercept(x)
|
||||||
|
out, _ = x.ModifyResponse(&http.Response{StatusCode: 200}, body)
|
||||||
|
json.Unmarshal(out, &l)
|
||||||
|
if fmt.Sprint(l["customers_room_id"]) != "[644 17]" || fmt.Sprint(l["t5de_t5"]) != "[1 0]" {
|
||||||
|
t.Fatalf("page 2: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBadgesAndRoomInfo(t *testing.T) {
|
||||||
|
r, _, _ := setup()
|
||||||
|
do := func(raw string, body []byte, modified []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
u, _ := url.Parse(raw)
|
||||||
|
req, _ := http.NewRequest("POST", u.String(), bytes.NewReader(body))
|
||||||
|
x := &proxy.Exchange{URL: u, Request: req, Body: body}
|
||||||
|
r.Intercept(x)
|
||||||
|
if x.Response != nil {
|
||||||
|
b, _ := io.ReadAll(x.Response.Body)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
if x.ModifyResponse == nil {
|
||||||
|
t.Fatalf("%s: left alone", raw)
|
||||||
|
}
|
||||||
|
out, _ := x.ModifyResponse(&http.Response{StatusCode: 200}, modified)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
// A decorated IMVU room's card gets t5de_t5.
|
||||||
|
card := (&xmlrpc.Call{Method: "test.getRoomCardInfo", Params: []xmlrpc.Value{xmlrpc.Struct{{Name: "room_instance_id", Value: "1-644"}}}}).Marshal()
|
||||||
|
answer := (&xmlrpc.Response{Params: []xmlrpc.Value{xmlrpc.Struct{{Name: "name", Value: "A"}}}}).Marshal()
|
||||||
|
if out := do("https://client-dynamic.imvu.com/api/xmlrpc/client.php", card, answer); !strings.Contains(string(out), "t5de_t5") {
|
||||||
|
t.Fatalf("card %s", out)
|
||||||
|
}
|
||||||
|
// The Manage tab's own decorated rooms get it too.
|
||||||
|
managed := []byte(`{"result": {"limit": 1, "rooms": [{"room_instance_id": "1-644"}, {"room_instance_id": "2-17"}]}}`)
|
||||||
|
out := do("https://client-dynamic.imvu.com/api/room_management_info.php", nil, managed)
|
||||||
|
if !strings.Contains(string(out), `{"room_instance_id":"1-644","t5de_t5":1}`) || strings.Count(string(out), "t5de_t5") != 1 {
|
||||||
|
t.Fatalf("managed %s", out)
|
||||||
|
}
|
||||||
|
// A decorated IMVU room's room_info.php gets t5de_t5.
|
||||||
|
if out := do("https://client-dynamic.imvu.com/api/rooms/room_info.php?room_id=1-644", nil, []byte(`{"participants": []}`)); !strings.Contains(string(out), `"t5de_t5":1`) {
|
||||||
|
t.Fatalf("room info %s", out)
|
||||||
|
}
|
||||||
|
// room_info.php for a hosted room: its people, from the hub.
|
||||||
|
out = do("https://client-dynamic.imvu.com/api/rooms/room_info.php?room_id=88-2100000003", nil, nil)
|
||||||
|
if !strings.Contains(string(out), `"avatar_name":"Erin"`) || !strings.Contains(string(out), `"customers_id":"88"`) ||
|
||||||
|
!strings.Contains(string(out), `"max_users":10`) || !strings.Contains(string(out), `"t5de_t5":1`) {
|
||||||
|
t.Fatalf("room info %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhase2(t *testing.T) {
|
||||||
|
r, h, _ := setup()
|
||||||
|
imvu := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
fmt.Fprintf(w, `{"denormalized": {"https://api.imvu.com/user/user-88": {"data": {"username": "Erin", "age": 30, "gender": "f", "thumbnail_url": "https://x/p.png"}}}}`)
|
||||||
|
}))
|
||||||
|
defer imvu.Close()
|
||||||
|
r.HTTPClient = &http.Client{Transport: rewrite{imvu.URL}}
|
||||||
|
var userMsgs []string
|
||||||
|
r.UserDeliver = func(mount string, b []byte) error { userMsgs = append(userMsgs, mount+" "+string(b)); return nil }
|
||||||
|
call := func(method string, args xmlrpc.Struct) xmlrpc.Value {
|
||||||
|
t.Helper()
|
||||||
|
resp, ok := xmlrpcCall(t, r, "chat.imvu.com", method, args)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s left to IMVU", method)
|
||||||
|
}
|
||||||
|
return resp.Params[0]
|
||||||
|
}
|
||||||
|
xmlrpcCall(t, r, "chat.imvu.com", "chat.getOrMakeChat", xmlrpc.Struct{{Name: "activity", Value: "publicroom-88-2100000003"}})
|
||||||
|
|
||||||
|
// Inviting: waiting until the hub says it was accepted.
|
||||||
|
first := call("chat.attemptInvite", xmlrpc.Struct{{Name: "partnerId", Value: int64(55)}, {Name: "location", Value: "88-2100000003"}})
|
||||||
|
id := get(first, "inviteId")
|
||||||
|
if get(first, "waiting") == nil || len(h.sentOf("room.invite")) != 1 || h.sentOf("room.invite")[0]["imvu_id"] != int64(55) {
|
||||||
|
t.Fatalf("first attempt %v, sent %v", first, h.sentOf("room.invite"))
|
||||||
|
}
|
||||||
|
r.FromHub([]byte(`{"type": "room.invite_result", "room": 3, "imvu_id": 55, "ok": true, "invite": "abc"}`))
|
||||||
|
if again := call("chat.attemptInvite", xmlrpc.Struct{{Name: "inviteId", Value: id}, {Name: "location", Value: "88-2100000003"}}); get(again, "waiting") == nil {
|
||||||
|
t.Fatalf("still waiting expected: %v", again)
|
||||||
|
}
|
||||||
|
r.FromHub([]byte(`{"type": "room.invite_answer", "invite": "abc", "to": 55, "accept": true}`))
|
||||||
|
if done := call("chat.attemptInvite", xmlrpc.Struct{{Name: "inviteId", Value: id}, {Name: "location", Value: "88-2100000003"}}); get(done, "waiting") != nil || get(done, "response") != nil {
|
||||||
|
t.Fatalf("accepted expected: %v", done)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invited: the client gets chatInvite on its user queue; accepting joins with the invite.
|
||||||
|
r.FromHub([]byte(`{"type": "room.invited", "room": 3, "invite": "xyz", "from": 88, "room_name": "Lounge"}`))
|
||||||
|
for i := 0; i < 100 && len(userMsgs) == 0; i++ {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if len(userMsgs) != 1 || !strings.HasPrefix(userMsgs[0], "web_msg chatInvite ") || !strings.Contains(userMsgs[0], `"room_instance_id":"88-2100000003"`) || !strings.Contains(userMsgs[0], `"maxOccupancy":10`) || !strings.Contains(userMsgs[0], `"gender":"f"`) || !strings.Contains(userMsgs[0], `"name":"Erin"`) {
|
||||||
|
t.Fatalf("invitation %v", userMsgs)
|
||||||
|
}
|
||||||
|
var inv map[string]any
|
||||||
|
json.Unmarshal([]byte(strings.TrimPrefix(userMsgs[0], "web_msg chatInvite ")), &inv)
|
||||||
|
// Before accepting (not in the room), getParticipants says who's in it, so the client sees its inviter there.
|
||||||
|
r.leave(3)
|
||||||
|
if ps := call("chat.getParticipants", xmlrpc.Struct{{Name: "chatId", Value: int64(2100000003)}}); !strings.Contains(fmt.Sprint(ps), "88") {
|
||||||
|
t.Fatalf("participants before joining %v", ps)
|
||||||
|
}
|
||||||
|
accepted := call("chat.acceptInvite", xmlrpc.Struct{{Name: "inviteId", Value: int64(inv["inviteId"].(float64))}, {Name: "chatId", Value: int64(2100000003)}})
|
||||||
|
if get(accepted, "room_instance_id") != "88-2100000003" || h.sentOf("room.invite_reply")[0]["accept"] != true {
|
||||||
|
t.Fatalf("accepting: %v, sent %v", accepted, h.sent)
|
||||||
|
}
|
||||||
|
// The client then joins the room, with the invite.
|
||||||
|
xmlrpcCall(t, r, "chat.imvu.com", "chat.getOrMakeChat", xmlrpc.Struct{{Name: "activity", Value: "publicroom-88-2100000003"}})
|
||||||
|
if joins := h.sentOf("room.join"); joins[len(joins)-1]["invite"] != "xyz" {
|
||||||
|
t.Fatalf("join after accepting %v", joins)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Booting: the *boot line also tells the hub; bootOutOfChat is answered here.
|
||||||
|
r.Send("/chat/2100000003", "messages", []byte(`{"message": "*boot 7 88", "userId": 7}`), nil, false)
|
||||||
|
if boots := h.sentOf("room.boot"); len(boots) != 1 || boots[0]["imvu_id"] != int64(88) {
|
||||||
|
t.Fatalf("boot %v", boots)
|
||||||
|
}
|
||||||
|
call("chat.bootOutOfChat", xmlrpc.Struct{{Name: "userId", Value: int64(88)}, {Name: "roomInstanceId", Value: "88-2100000003"}})
|
||||||
|
|
||||||
|
// Favorites.
|
||||||
|
resp, _ := xmlrpcCall(t, r, "client-dynamic.imvu.com", "test.addFavoriteRoom", xmlrpc.Struct{{Name: "room_instance_id", Value: "88-2100000003"}})
|
||||||
|
if resp == nil || len(h.sentOf("PUT /api/v1/hosted-rooms/3/favorite")) != 1 {
|
||||||
|
t.Fatal("favorite not added")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favorites from the room list.
|
||||||
|
fu, _ := url.Parse("https://client-dynamic.imvu.com/api/favorite_rooms.php")
|
||||||
|
fbody := []byte(`{"action": "remove", "room_instance_id": "88-2100000003"}`)
|
||||||
|
freq, _ := http.NewRequest("POST", fu.String(), bytes.NewReader(fbody))
|
||||||
|
fx := &proxy.Exchange{URL: fu, Request: freq, Body: fbody}
|
||||||
|
r.Intercept(fx)
|
||||||
|
if fx.Response == nil || len(h.sentOf("DELETE /api/v1/hosted-rooms/3/favorite")) != 1 {
|
||||||
|
t.Fatal("favorite not removed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rating.
|
||||||
|
u, _ := url.Parse("https://client-dynamic.imvu.com/api/rooms/room_rating.php")
|
||||||
|
body := []byte("room_instance_id=88-2100000003&score=4")
|
||||||
|
req, _ := http.NewRequest("POST", u.String(), bytes.NewReader(body))
|
||||||
|
x := &proxy.Exchange{URL: u, Request: req, Body: body}
|
||||||
|
r.Intercept(x)
|
||||||
|
if x.Response == nil || len(h.sentOf("PUT /api/v1/hosted-rooms/3/rating")) != 1 {
|
||||||
|
t.Fatal("rating not sent")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Occupancy: watching tells the hub; changes reach the watcher as state changes.
|
||||||
|
var toClient bytes.Buffer
|
||||||
|
c := proxy.NewIMQConn(2, &toClient)
|
||||||
|
r.Route(c, false, imq.Subscription{Queues: []string{"room_occupancy/88-2100000003"}})
|
||||||
|
if w := h.sentOf("room.watch"); len(w) == 0 || fmt.Sprint(w[len(w)-1]["rooms"]) != "[3]" {
|
||||||
|
t.Fatalf("watch %v", w)
|
||||||
|
}
|
||||||
|
toClient.Reset()
|
||||||
|
r.FromHub([]byte(`{"type": "room.occupancy", "room": 3, "participants": [{"imvu_id": 88, "seat": 1}, {"imvu_id": 7, "seat": 2}]}`))
|
||||||
|
fs := frames(t, toClient.Bytes())
|
||||||
|
if len(fs) != 1 || fs[0].Type != imq.G2cStateChange || !strings.Contains(string(fs[0].Data), "room_occupancy/88-2100000003") {
|
||||||
|
t.Fatalf("occupancy frames %v", fs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendRoomToEmptyList(t *testing.T) {
|
||||||
|
list := map[string]json.RawMessage{"customers_id": json.RawMessage(`[]`), "is_ap": json.RawMessage(`[]`), "is_vip": json.RawMessage(`[]`), "name": json.RawMessage(`[]`)}
|
||||||
|
appendRoom(list, Room{N: 3, OwnerImvuID: 88, Name: "Lounge"})
|
||||||
|
if string(list["is_ap"]) != "[0]" || string(list["is_vip"]) != "[0]" || string(list["name"]) != `["Lounge"]` {
|
||||||
|
t.Fatalf("list %s %s %s", list["is_ap"], list["is_vip"], list["name"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rewrite sends every request to a test server.
|
||||||
|
type rewrite struct{ to string }
|
||||||
|
|
||||||
|
func (t rewrite) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
u, _ := url.Parse(t.to + req.URL.Path)
|
||||||
|
req = req.Clone(req.Context())
|
||||||
|
req.URL, req.Host = u, u.Host
|
||||||
|
return http.DefaultTransport.RoundTrip(req)
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package hosted
|
||||||
|
|
||||||
|
// Managing the person's own hosted rooms from the client's Chat Rooms > Manage tab, which shows them in a list of
|
||||||
|
// their own (the t5de-rooms patch): room_management_info.php?t5de=1 lists them as IMVU lists the person's rooms, and
|
||||||
|
// IMVU's room dialog saves them (room_management.php with t5de=1, or naming a hosted room), as calls to the hub.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxRoomsPerPerson is the hub's cap on a person's open rooms (t5de-hub's maxRoomsPerPerson).
|
||||||
|
const maxRoomsPerPerson = 10
|
||||||
|
|
||||||
|
// uploader is a hub that takes files (hub.Client).
|
||||||
|
type uploader interface {
|
||||||
|
Upload(ctx context.Context, method, path, field, filename, contentType string, file []byte, out any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// manageInfo answers room_management_info.php?t5de=1: the person's hosted rooms owned by the account in the client,
|
||||||
|
// and how many more they may make.
|
||||||
|
func (r *Rooms) manageInfo(ctx context.Context) *http.Response {
|
||||||
|
var out struct {
|
||||||
|
Rooms []Room `json:"rooms"`
|
||||||
|
}
|
||||||
|
if err := r.Hub.Call(ctx, "GET", "/api/v1/hosted-rooms?mine=1", nil, &out); err != nil {
|
||||||
|
r.logf("hosted: the person's rooms: %v", err)
|
||||||
|
return manageJSON(map[string]any{"error": "T5DE's hub isn't available."})
|
||||||
|
}
|
||||||
|
me := r.user()
|
||||||
|
rooms := []any{}
|
||||||
|
for _, rm := range out.Rooms {
|
||||||
|
if rm.OwnerImvuID == me {
|
||||||
|
rooms = append(rooms, manageRow(rm))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return manageJSON(map[string]any{"result": map[string]any{"rooms": rooms, "limit": maxRoomsPerPerson - len(out.Rooms) + len(rooms)}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// manageRow is a hosted room as room_management_info.php lists the person's rooms (and the room dialog edits them).
|
||||||
|
func manageRow(rm Room) map[string]any {
|
||||||
|
adult, open := 0, 2 // 2: public, as IMVU's "Public"
|
||||||
|
if rm.Rating == "adult" {
|
||||||
|
adult = 1
|
||||||
|
}
|
||||||
|
if rm.Privacy == "private" {
|
||||||
|
open = 0
|
||||||
|
}
|
||||||
|
var mods []string
|
||||||
|
for _, id := range rm.Moderators {
|
||||||
|
mods = append(mods, strconv.FormatInt(id, 10))
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"name": plain(rm.Name), "description": plain(rm.Description), "customers_id": strconv.FormatInt(rm.OwnerImvuID, 10),
|
||||||
|
"customers_room_id": strconv.FormatInt(Base+rm.N, 10), "room_instance_id": instanceID(rm.OwnerImvuID, rm.N),
|
||||||
|
"image_url": rm.Image, "num_participants": rm.Occupancy, "max_users": rm.MaxUsers, "room_pid": rm.RoomPid,
|
||||||
|
"is_ap": adult, "is_vip": 0, "is_age_verified_only": 0, "is_non_guest_only": 0, "is_friends_only": 0,
|
||||||
|
"is_favorite": boolNumber(rm.Favorite), "language": "", "open": open, "lieutenants": strings.Join(mods, ", "),
|
||||||
|
"t5de_t5": 1, "t5de_hosted": 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// manage answers room_management.php for hosted rooms: action create (with t5de=1), edit or delete (naming one).
|
||||||
|
func (r *Rooms) manage(ctx context.Context, x *proxy.Exchange) bool {
|
||||||
|
form := params(x)
|
||||||
|
n, hosted := RoomNumber(form.Get("room_instance_id"))
|
||||||
|
action := form.Get("action")
|
||||||
|
if !hosted && !(action == "create" && form.Get("t5de") == "1") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if action == "delete" {
|
||||||
|
if err := r.Hub.Call(ctx, "DELETE", fmt.Sprintf("/api/v1/hosted-rooms/%d", n), nil, nil); err != nil {
|
||||||
|
x.Response = manageErrors(err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
x.Response = manageJSON(map[string]any{"result": true})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if action != "create" && action != "edit" {
|
||||||
|
x.Response = manageJSON(map[string]any{"errors": []any{map[string]string{"0": "T5DE rooms can't do that."}}})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
room := map[string]any{"name": strings.TrimSpace(form.Get("room_name")), "description": form.Get("room_description")}
|
||||||
|
if pid, err := strconv.ParseInt(form.Get("room_pid"), 10, 64); err == nil && pid > 0 && action == "create" {
|
||||||
|
room["roomPid"] = pid // (the dialog doesn't show the room when editing)
|
||||||
|
}
|
||||||
|
if max, err := strconv.Atoi(form.Get("max_users")); err == nil && max > 0 {
|
||||||
|
room["maxUsers"] = max
|
||||||
|
}
|
||||||
|
room["rating"] = "general"
|
||||||
|
if ap := form.Get("ap"); ap != "" && ap != "0" {
|
||||||
|
room["rating"] = "adult"
|
||||||
|
}
|
||||||
|
room["privacy"] = "public"
|
||||||
|
if form.Get("permissions") == "0" {
|
||||||
|
room["privacy"] = "private"
|
||||||
|
}
|
||||||
|
var answer struct {
|
||||||
|
Room Room `json:"room"`
|
||||||
|
}
|
||||||
|
if action == "create" {
|
||||||
|
if room["roomPid"] == nil {
|
||||||
|
x.Response = manageErrors(errors.New("Choose a room."))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
room["imvuId"] = r.user()
|
||||||
|
if err := r.Hub.Call(ctx, "POST", "/api/v1/hosted-rooms", room, &answer); err != nil {
|
||||||
|
x.Response = manageErrors(err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
n = answer.Room.N
|
||||||
|
}
|
||||||
|
// Moderators by name or ID ("a, b"), set after creating (the hub looks names up).
|
||||||
|
mods := []string{}
|
||||||
|
for _, m := range strings.FieldsFunc(form.Get("lieutenants"), func(c rune) bool { return c == ',' || c == ';' || c == '\n' }) {
|
||||||
|
if m = strings.TrimSpace(m); m != "" {
|
||||||
|
mods = append(mods, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
change := room
|
||||||
|
if action == "create" {
|
||||||
|
change = map[string]any{}
|
||||||
|
}
|
||||||
|
if action == "edit" || len(mods) > 0 {
|
||||||
|
change["moderators"] = mods
|
||||||
|
}
|
||||||
|
if len(change) > 0 {
|
||||||
|
if err := r.Hub.Call(ctx, "PATCH", fmt.Sprintf("/api/v1/hosted-rooms/%d", n), change, &answer); err != nil {
|
||||||
|
x.Response = manageErrors(err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := r.setImage(ctx, n, answer.Room.Image, form.Get("image"), &answer.Room); err != nil {
|
||||||
|
x.Response = manageErrors(err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
if r.owners == nil {
|
||||||
|
r.owners = map[int64]int64{}
|
||||||
|
}
|
||||||
|
r.owners[n] = answer.Room.OwnerImvuID
|
||||||
|
r.mu.Unlock()
|
||||||
|
x.Response = manageJSON(map[string]any{"result": manageRow(answer.Room)})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// setImage gives a room the image chosen in the dialog (a picture from the person's IMVU gallery, uploaded to the hub),
|
||||||
|
// or takes it away ("" when it had one). The hub's own image URL means unchanged.
|
||||||
|
func (r *Rooms) setImage(ctx context.Context, n int64, current, chosen string, into *Room) error {
|
||||||
|
if chosen == current || (chosen != "" && current != "" && strings.SplitN(chosen, "?", 2)[0] == strings.SplitN(current, "?", 2)[0]) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var answer struct {
|
||||||
|
Room Room `json:"room"`
|
||||||
|
}
|
||||||
|
path := fmt.Sprintf("/api/v1/hosted-rooms/%d/image", n)
|
||||||
|
if chosen == "" {
|
||||||
|
if current == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := r.Hub.Call(ctx, "DELETE", path, nil, &answer); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*into = answer.Room
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
up, ok := r.Hub.(uploader)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("T5DE's hub can't take images here.")
|
||||||
|
}
|
||||||
|
u, err := url.Parse(chosen)
|
||||||
|
if err != nil || !(u.Scheme == "http" || u.Scheme == "https") || !strings.HasSuffix(strings.ToLower(u.Hostname()), ".imvu.com") {
|
||||||
|
return errors.New("Choose a picture from your gallery.")
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := r.client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("Couldn't get that picture from IMVU.")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 512<<10+1))
|
||||||
|
if err != nil || resp.StatusCode != http.StatusOK {
|
||||||
|
return errors.New("Couldn't get that picture from IMVU.")
|
||||||
|
}
|
||||||
|
if len(data) > 512<<10 {
|
||||||
|
return errors.New("That picture is too big for a T5DE room (512 KB at most).")
|
||||||
|
}
|
||||||
|
if err := up.Upload(ctx, "PUT", path, "image", "room", http.DetectContentType(data), data, &answer); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*into = answer.Room
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func manageJSON(v any) *http.Response {
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
return proxy.LocalResponse(200, "application/json", b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// manageErrors is a failed save as the room dialog shows it.
|
||||||
|
func manageErrors(err error) *http.Response {
|
||||||
|
msg := err.Error()
|
||||||
|
var he *hub.Error
|
||||||
|
if errors.As(err, &he) {
|
||||||
|
msg = he.Message
|
||||||
|
if msg == "" {
|
||||||
|
msg = "T5DE's hub didn't take that. Check the room's details."
|
||||||
|
}
|
||||||
|
} else if !strings.HasSuffix(msg, ".") {
|
||||||
|
msg = "T5DE's hub isn't available."
|
||||||
|
}
|
||||||
|
return manageJSON(map[string]any{"errors": []any{map[string]string{"0": msg}}})
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package hosted
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// manageHub is a hub for the Manage tab: the person's rooms, and what was asked of it.
|
||||||
|
type manageHub struct {
|
||||||
|
calls []string
|
||||||
|
bodies []map[string]any
|
||||||
|
uploads [][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *manageHub) Send(v any) bool { return true }
|
||||||
|
|
||||||
|
func (h *manageHub) Call(ctx context.Context, method, path string, body, out any) error {
|
||||||
|
h.calls = append(h.calls, method+" "+path)
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
var m map[string]any
|
||||||
|
json.Unmarshal(b, &m)
|
||||||
|
h.bodies = append(h.bodies, m)
|
||||||
|
room := `{"room": {"n": 4, "name": "Den", "ownerImvuId": 7, "roomPid": 555, "rating": "general", "maxUsers": 6,
|
||||||
|
"privacy": "private", "moderators": [88], "image": "http://hub/api/v1/hosted-rooms/4/image?v=1"}}`
|
||||||
|
switch {
|
||||||
|
case method == "GET" && path == "/api/v1/hosted-rooms?mine=1":
|
||||||
|
return json.Unmarshal([]byte(`{"rooms": [{"n": 4, "name": "Den", "ownerImvuId": 7, "maxUsers": 6, "privacy": "private", "moderators": [88]},
|
||||||
|
{"n": 5, "name": "Other account's", "ownerImvuId": 99, "maxUsers": 10}]}`), out)
|
||||||
|
case method == "POST" && path == "/api/v1/hosted-rooms":
|
||||||
|
if m["name"] == "" {
|
||||||
|
return &hub.Error{Status: 400, Code: "invalid", Message: "A room needs a name."}
|
||||||
|
}
|
||||||
|
return json.Unmarshal([]byte(room), out)
|
||||||
|
case (method == "PATCH" || method == "DELETE") && strings.HasPrefix(path, "/api/v1/hosted-rooms/4"):
|
||||||
|
if out != nil {
|
||||||
|
return json.Unmarshal([]byte(room), out)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("unexpected %s %s", method, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *manageHub) Upload(ctx context.Context, method, path, field, filename, contentType string, file []byte, out any) error {
|
||||||
|
h.calls = append(h.calls, "UPLOAD "+path)
|
||||||
|
h.uploads = append(h.uploads, file)
|
||||||
|
return json.Unmarshal([]byte(`{"room": {"n": 4, "name": "Den", "ownerImvuId": 7, "image": "http://hub/api/v1/hosted-rooms/4/image?v=2"}}`), out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func manageCall(t *testing.T, r *Rooms, path string, form url.Values) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
u, _ := url.Parse("https://client-dynamic.imvu.com" + path)
|
||||||
|
var body []byte
|
||||||
|
method := "GET"
|
||||||
|
if form != nil {
|
||||||
|
method = "POST"
|
||||||
|
m := map[string]string{}
|
||||||
|
for k := range form {
|
||||||
|
m[k] = form.Get(k)
|
||||||
|
}
|
||||||
|
body, _ = json.Marshal(m) // as imvu.http.securePost sends it
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(method, u.String(), bytes.NewReader(body))
|
||||||
|
x := &proxy.Exchange{URL: u, Request: req, Body: body}
|
||||||
|
r.Intercept(x)
|
||||||
|
if x.Response == nil {
|
||||||
|
t.Fatalf("%s left to IMVU", path)
|
||||||
|
}
|
||||||
|
b, _ := io.ReadAll(x.Response.Body)
|
||||||
|
var out map[string]any
|
||||||
|
if err := json.Unmarshal(b, &out); err != nil {
|
||||||
|
t.Fatalf("%s: %s", path, b)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManage(t *testing.T) {
|
||||||
|
h := &manageHub{}
|
||||||
|
gallery := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
w.Write([]byte("\x89PNG\r\n\x1a\n picture"))
|
||||||
|
}))
|
||||||
|
defer gallery.Close()
|
||||||
|
r := &Rooms{Hub: h, User: func() int64 { return 7 }, HTTPClient: &http.Client{Transport: rewrite{gallery.URL}}}
|
||||||
|
|
||||||
|
// The list: this account's rooms, as IMVU lists the person's; the cap counts the person's other rooms.
|
||||||
|
info := manageCall(t, r, "/api/room_management_info.php?t5de=1", nil)
|
||||||
|
res := info["result"].(map[string]any)
|
||||||
|
rooms := res["rooms"].([]any)
|
||||||
|
if len(rooms) != 1 || res["limit"] != float64(9) {
|
||||||
|
t.Fatalf("info %v", info)
|
||||||
|
}
|
||||||
|
row := rooms[0].(map[string]any)
|
||||||
|
if row["room_instance_id"] != "7-2100000004" || row["open"] != float64(0) || row["lieutenants"] != "88" || row["t5de_t5"] != float64(1) {
|
||||||
|
t.Fatalf("row %v", row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creating: the room, then its moderators, then its picture from the gallery.
|
||||||
|
created := manageCall(t, r, "/api/room_management.php", url.Values{"action": {"create"}, "t5de": {"1"}, "room_name": {"Den"},
|
||||||
|
"room_pid": {"555"}, "max_users": {"6"}, "permissions": {"0"}, "ap": {"0"}, "lieutenants": {"Erin, 99"},
|
||||||
|
"image": {"http://userimages.imvu.com/userdata/p.png"}})
|
||||||
|
if created["result"].(map[string]any)["room_instance_id"] != "7-2100000004" {
|
||||||
|
t.Fatalf("created %v", created)
|
||||||
|
}
|
||||||
|
if fmt.Sprint(h.calls) != "[GET /api/v1/hosted-rooms?mine=1 POST /api/v1/hosted-rooms PATCH /api/v1/hosted-rooms/4 UPLOAD /api/v1/hosted-rooms/4/image]" {
|
||||||
|
t.Fatalf("calls %v", h.calls)
|
||||||
|
}
|
||||||
|
if b := h.bodies[1]; b["imvuId"] != float64(7) || b["privacy"] != "private" || b["roomPid"] != float64(555) || b["rating"] != "general" {
|
||||||
|
t.Fatalf("create body %v", b)
|
||||||
|
}
|
||||||
|
if fmt.Sprint(h.bodies[2]["moderators"]) != "[Erin 99]" || len(h.uploads) != 1 {
|
||||||
|
t.Fatalf("moderators %v, uploads %d", h.bodies[2], len(h.uploads))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editing with the hub's own picture keeps it; a gallery picture elsewhere than IMVU is refused.
|
||||||
|
h.calls = nil
|
||||||
|
manageCall(t, r, "/api/room_management.php", url.Values{"action": {"edit"}, "room_instance_id": {"7-2100000004"}, "room_name": {"Den 2"},
|
||||||
|
"room_pid": {"777"}, "permissions": {"2"}, "image": {"http://hub/api/v1/hosted-rooms/4/image"}})
|
||||||
|
if fmt.Sprint(h.calls) != "[PATCH /api/v1/hosted-rooms/4]" || h.bodies[len(h.bodies)-1]["privacy"] != "public" || h.bodies[len(h.bodies)-1]["roomPid"] != nil {
|
||||||
|
t.Fatalf("edit calls %v %v", h.calls, h.bodies[len(h.bodies)-1])
|
||||||
|
}
|
||||||
|
bad := manageCall(t, r, "/api/room_management.php", url.Values{"action": {"edit"}, "room_instance_id": {"7-2100000004"},
|
||||||
|
"room_name": {"Den"}, "image": {"https://evil.example/p.png"}})
|
||||||
|
if !strings.Contains(fmt.Sprint(bad["errors"]), "gallery") {
|
||||||
|
t.Fatalf("bad image %v", bad)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The hub's refusals are shown in the dialog.
|
||||||
|
refused := manageCall(t, r, "/api/room_management.php", url.Values{"action": {"create"}, "t5de": {"1"}, "room_name": {""}, "room_pid": {"555"}})
|
||||||
|
if !strings.Contains(fmt.Sprint(refused["errors"]), "A room needs a name.") {
|
||||||
|
t.Fatalf("refused %v", refused)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting.
|
||||||
|
h.calls = nil
|
||||||
|
manageCall(t, r, "/api/room_management.php", url.Values{"action": {"delete"}, "room_instance_id": {"7-2100000004"}})
|
||||||
|
if fmt.Sprint(h.calls) != "[DELETE /api/v1/hosted-rooms/4]" {
|
||||||
|
t.Fatalf("delete %v", h.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMVU's own rooms are IMVU's.
|
||||||
|
u, _ := url.Parse("https://client-dynamic.imvu.com/api/room_management.php")
|
||||||
|
body := []byte(`{"action": "edit", "room_instance_id": "7-3"}`)
|
||||||
|
req, _ := http.NewRequest("POST", u.String(), bytes.NewReader(body))
|
||||||
|
x := &proxy.Exchange{URL: u, Request: req, Body: body}
|
||||||
|
r.Intercept(x)
|
||||||
|
if x.Response != nil {
|
||||||
|
t.Fatal("an IMVU room's edit was answered here")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
package hosted
|
||||||
|
|
||||||
|
// Hosted rooms phase 2 (t5de-hub's docs/hosted-rooms.md, "Phase 2"): the client's own room actions, for hosted
|
||||||
|
// rooms. Boots are the room's "*boot <booter> <booted>" chat line (every client acts on it) and a room.boot to the
|
||||||
|
// hub; invitations go through the hub (the inviter's client polls chat.attemptInvite; the invitee's gets a
|
||||||
|
// "chatInvite" on its user queue); favorites, ratings and the room list's occupancy come from the hub.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/imq"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/xmlrpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The client's reason codes for refused joins: booted (it shows "booted"), and full (IMVU's own wording).
|
||||||
|
const (
|
||||||
|
errorPublicRoomFull = 5
|
||||||
|
errorPublicRoomBooted = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// invite is an invitation the client sent (outgoing, by the local ID the client knows) or got (incoming).
|
||||||
|
type invite struct {
|
||||||
|
room int64
|
||||||
|
hub string // the hub's invite ID ("" until the hub answers)
|
||||||
|
to int64 // outgoing: who
|
||||||
|
from int64 // incoming: who
|
||||||
|
state string // outgoing: "waiting", "accepted", "declined"
|
||||||
|
note string // why it was declined
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Rooms) newInviteLocked(in *invite) int64 {
|
||||||
|
if r.invites == nil {
|
||||||
|
r.invites = map[int64]*invite{}
|
||||||
|
}
|
||||||
|
r.nextInvite++
|
||||||
|
r.invites[r.nextInvite] = in
|
||||||
|
return r.nextInvite
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- from the hub ---
|
||||||
|
|
||||||
|
func (r *Rooms) phase2FromHub(kind string, data []byte) {
|
||||||
|
var m struct {
|
||||||
|
Room int64 `json:"room"`
|
||||||
|
ImvuID int64 `json:"imvu_id"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Invite string `json:"invite"`
|
||||||
|
To int64 `json:"to"`
|
||||||
|
From int64 `json:"from"`
|
||||||
|
Accept bool `json:"accept"`
|
||||||
|
Offline bool `json:"offline"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
RoomName string `json:"room_name"`
|
||||||
|
Participants []Participant `json:"participants"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(data, &m) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch kind {
|
||||||
|
case "room.invite_result":
|
||||||
|
r.mu.Lock()
|
||||||
|
for _, in := range r.invites {
|
||||||
|
if in.room == m.Room && in.to == m.ImvuID && in.hub == "" && in.state == "waiting" {
|
||||||
|
if m.OK {
|
||||||
|
in.hub = m.Invite
|
||||||
|
} else {
|
||||||
|
in.state, in.note = "declined", m.Message
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
case "room.invite_answer":
|
||||||
|
r.mu.Lock()
|
||||||
|
for _, in := range r.invites {
|
||||||
|
if in.hub == m.Invite && in.state == "waiting" {
|
||||||
|
switch {
|
||||||
|
case m.Offline:
|
||||||
|
in.state, in.note = "declined", "isn't online"
|
||||||
|
case m.Accept:
|
||||||
|
in.state = "accepted"
|
||||||
|
default:
|
||||||
|
in.state, in.note = "declined", m.Note
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
case "room.invited":
|
||||||
|
go r.deliverInvite(m.Room, m.Invite, m.From)
|
||||||
|
case "room.occupancy":
|
||||||
|
r.occupancy(m.Room, m.Participants)
|
||||||
|
case "room.boot_result":
|
||||||
|
if !m.OK {
|
||||||
|
r.logf("hosted: booting %d from room %d: %s", m.ImvuID, m.Room, m.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverInvite gives the client an invitation into a hosted room, as IMVU's server does ("chatInvite <json>" on its
|
||||||
|
// user queue, mount web_msg).
|
||||||
|
func (r *Rooms) deliverInvite(n int64, hubID string, from int64) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
rm, _, _, err := r.getRoom(ctx, n)
|
||||||
|
if err != nil || r.UserDeliver == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inviter := r.profile(ctx, from)
|
||||||
|
for _, p := range r.people(ctx, n) {
|
||||||
|
if p.ImvuID == from && p.Name != "" {
|
||||||
|
inviter["name"] = plain(p.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
local := r.newInviteLocked(&invite{room: n, hub: hubID, from: from})
|
||||||
|
r.mu.Unlock()
|
||||||
|
rating := 0
|
||||||
|
if rm.Rating == "adult" {
|
||||||
|
rating = 1
|
||||||
|
}
|
||||||
|
instance := instanceID(rm.OwnerImvuID, n)
|
||||||
|
// location is what the invitation dialog shows of the room.
|
||||||
|
location := map[string]any{"name": plain(rm.Name), "private": rm.Privacy == "private", "imageUrl": rm.Image,
|
||||||
|
"ownerName": plain(rm.OwnerName), "language": "", "currentOccupancy": rm.Occupancy, "maxOccupancy": rm.MaxUsers,
|
||||||
|
"description": plain(rm.Description), "rating": rating, "room_instance_id": instance}
|
||||||
|
b, _ := json.Marshal(map[string]any{"chatId": Base + n, "inviteId": local, "partnerId": from,
|
||||||
|
"location": location, "inviter": inviter})
|
||||||
|
if err := r.UserDeliver("web_msg", append([]byte("chatInvite "), b...)); err != nil {
|
||||||
|
r.logf("hosted: giving the client an invitation: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// profile is what the invitation dialog shows of the inviter, from IMVU's public profile.
|
||||||
|
func (r *Rooms) profile(ctx context.Context, id int64) map[string]any {
|
||||||
|
out := map[string]any{"name": "", "age": "", "gender": "", "imageUrl": "", "relationship": ""}
|
||||||
|
u := fmt.Sprintf("https://api.imvu.com/user/user-%d", id)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
resp, err := r.client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var body struct {
|
||||||
|
Denormalized map[string]struct {
|
||||||
|
Data struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Age any `json:"age"`
|
||||||
|
Gender string `json:"gender"`
|
||||||
|
ThumbnailURL string `json:"thumbnail_url"`
|
||||||
|
} `json:"data"`
|
||||||
|
} `json:"denormalized"`
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&body) != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
d := body.Denormalized[u].Data
|
||||||
|
out["name"], out["gender"], out["imageUrl"] = plain(d.Username), d.Gender, d.ThumbnailURL
|
||||||
|
if d.Age != nil {
|
||||||
|
out["age"] = fmt.Sprint(d.Age)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Rooms) client() *http.Client {
|
||||||
|
if r.HTTPClient != nil {
|
||||||
|
return r.HTTPClient
|
||||||
|
}
|
||||||
|
return http.DefaultClient
|
||||||
|
}
|
||||||
|
|
||||||
|
type person struct {
|
||||||
|
ImvuID int64 `json:"imvu_id"`
|
||||||
|
Seat int `json:"seat"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// people is who's in a room, with names.
|
||||||
|
func (r *Rooms) people(ctx context.Context, n int64) []person {
|
||||||
|
var out struct {
|
||||||
|
Participants []person `json:"participants"`
|
||||||
|
}
|
||||||
|
r.Hub.Call(ctx, "GET", fmt.Sprintf("/api/v1/hosted-rooms/%d/participants", n), nil, &out)
|
||||||
|
return out.Participants
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the client's calls ---
|
||||||
|
|
||||||
|
// interceptPhase2 answers the client's room actions for hosted rooms; false if the call isn't one.
|
||||||
|
func (r *Rooms) interceptPhase2(ctx context.Context, x *proxy.Exchange, method string, args xmlrpc.Struct) bool {
|
||||||
|
switch method {
|
||||||
|
case "chat.attemptInvite":
|
||||||
|
n, ok := RoomNumber(member(args, "location"))
|
||||||
|
if !ok {
|
||||||
|
if chat, err := strconv.ParseInt(member(args, "chatId"), 10, 64); err == nil {
|
||||||
|
n, ok = ChatRoom(chat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
x.Response = r.attemptInvite(n, args)
|
||||||
|
return true
|
||||||
|
case "chat.leaveInviteAwayNote":
|
||||||
|
if r.knownInvite(member(args, "inviteId")) {
|
||||||
|
x.Response = xmlrpcAnswer(true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case "chat.acceptInvite", "chat.declineInvite":
|
||||||
|
local, _ := strconv.ParseInt(member(args, "inviteId"), 10, 64)
|
||||||
|
r.mu.Lock()
|
||||||
|
in, ok := r.invites[local]
|
||||||
|
r.mu.Unlock()
|
||||||
|
if !ok || in.from == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
accept := method == "chat.acceptInvite"
|
||||||
|
reply := map[string]any{"type": "room.invite_reply", "invite": in.hub, "accept": accept}
|
||||||
|
if note := member(args, "reason"); !accept && note != "" {
|
||||||
|
reply["note"] = note
|
||||||
|
}
|
||||||
|
r.Hub.Send(reply)
|
||||||
|
if !accept {
|
||||||
|
x.Response = xmlrpcAnswer(true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// The client joins the room itself when the answer names it (as a public room, not a private chat); that
|
||||||
|
// join carries the invite.
|
||||||
|
r.mu.Lock()
|
||||||
|
if r.accepted == nil {
|
||||||
|
r.accepted = map[int64]string{}
|
||||||
|
}
|
||||||
|
r.accepted[in.room] = in.hub
|
||||||
|
instance := r.instanceOfLocked(in.room) // known: the room was looked up for the invitation
|
||||||
|
r.mu.Unlock()
|
||||||
|
x.Response = xmlrpcAnswer(xmlrpc.Struct{{Name: "chatId", Value: Base + in.room}, {Name: "partnerId", Value: in.from},
|
||||||
|
{Name: "room_instance_id", Value: instance}})
|
||||||
|
return true
|
||||||
|
case "chat.bootOutOfChat":
|
||||||
|
// The "*boot" chat line already told the hub (Send); the client only wants an answer.
|
||||||
|
if _, ok := RoomNumber(member(args, "roomInstanceId")); ok {
|
||||||
|
x.Response = xmlrpcAnswer(true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case "test.addFavoriteRoom", "test.removeFavoriteRoom":
|
||||||
|
n, ok := RoomNumber(member(args, "room_instance_id"))
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
verb := "PUT"
|
||||||
|
if method == "test.removeFavoriteRoom" {
|
||||||
|
verb = "DELETE"
|
||||||
|
}
|
||||||
|
if err := r.Hub.Call(ctx, verb, fmt.Sprintf("/api/v1/hosted-rooms/%d/favorite", n), map[string]any{}, nil); err != nil {
|
||||||
|
x.Response = xmlrpcAnswer(xmlrpc.Struct{{Name: "error", Value: "T5DE's hub didn't take it: " + err.Error()}})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
x.Response = xmlrpcAnswer(xmlrpc.Struct{})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Rooms) knownInvite(id string) bool {
|
||||||
|
local, err := strconv.ParseInt(id, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
_, ok := r.invites[local]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// attemptInvite is the inviter's poll: the first call sends the invitation; later ones (with its inviteId) say
|
||||||
|
// whether it's been answered.
|
||||||
|
func (r *Rooms) attemptInvite(n int64, args xmlrpc.Struct) *http.Response {
|
||||||
|
local, _ := strconv.ParseInt(member(args, "inviteId"), 10, 64)
|
||||||
|
r.mu.Lock()
|
||||||
|
in, ok := r.invites[local]
|
||||||
|
if !ok {
|
||||||
|
to, _ := strconv.ParseInt(member(args, "partnerId"), 10, 64)
|
||||||
|
in = &invite{room: n, to: to, state: "waiting"}
|
||||||
|
local = r.newInviteLocked(in)
|
||||||
|
}
|
||||||
|
state, note := in.state, in.note
|
||||||
|
r.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
r.Hub.Send(map[string]any{"type": "room.invite", "room": n, "imvu_id": in.to})
|
||||||
|
}
|
||||||
|
switch state {
|
||||||
|
case "accepted":
|
||||||
|
return xmlrpcAnswer(xmlrpc.Struct{{Name: "inviteId", Value: local}, {Name: "chatId", Value: Base + n}})
|
||||||
|
case "declined":
|
||||||
|
if note == "" {
|
||||||
|
note = "declined"
|
||||||
|
}
|
||||||
|
return xmlrpcAnswer(xmlrpc.Struct{{Name: "inviteId", Value: local}, {Name: "response", Value: "decline"}, {Name: "reason", Value: note}})
|
||||||
|
}
|
||||||
|
return xmlrpcAnswer(xmlrpc.Struct{{Name: "inviteId", Value: local}, {Name: "waiting", Value: int64(1)}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// boot tells the hub about a "*boot <booter> <booted>" line the client sent in a hosted room.
|
||||||
|
func (r *Rooms) boot(n int64, text string) {
|
||||||
|
f := strings.Fields(text)
|
||||||
|
if len(f) != 3 || f[0] != "*boot" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
booted, err := strconv.ParseInt(f[2], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Hub.Send(map[string]any{"type": "room.boot", "room": n, "imvu_id": booted})
|
||||||
|
}
|
||||||
|
|
||||||
|
// favoriteForm answers the room list's favorite_rooms.php (action=add|remove, room_instance_id) for hosted rooms.
|
||||||
|
func (r *Rooms) favoriteForm(ctx context.Context, x *proxy.Exchange) bool {
|
||||||
|
form := params(x)
|
||||||
|
n, ok := RoomNumber(form.Get("room_instance_id"))
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
verb := "PUT"
|
||||||
|
if form.Get("action") == "remove" {
|
||||||
|
verb = "DELETE"
|
||||||
|
}
|
||||||
|
if err := r.Hub.Call(ctx, verb, fmt.Sprintf("/api/v1/hosted-rooms/%d/favorite", n), map[string]any{}, nil); err != nil {
|
||||||
|
b, _ := json.Marshal(map[string]any{"error": "T5DE's hub didn't take it: " + err.Error()})
|
||||||
|
x.Response = proxy.LocalResponse(200, "application/json", b)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
x.Response = proxy.LocalResponse(200, "application/json", []byte(`{"result":"success"}`))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// params is a web API call's parameters: its query, then its body (JSON, as imvu.http.securePost sends, or a form).
|
||||||
|
func params(x *proxy.Exchange) url.Values {
|
||||||
|
out := x.URL.Query()
|
||||||
|
var body map[string]any
|
||||||
|
if json.Unmarshal(x.Body, &body) == nil {
|
||||||
|
for k, v := range body {
|
||||||
|
switch v := v.(type) {
|
||||||
|
case string:
|
||||||
|
out.Set(k, v)
|
||||||
|
case float64:
|
||||||
|
out.Set(k, strconv.FormatFloat(v, 'f', -1, 64))
|
||||||
|
case bool:
|
||||||
|
out.Set(k, strconv.FormatBool(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if form, err := url.ParseQuery(string(x.Body)); err == nil {
|
||||||
|
for k, v := range form {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ratings (room_rating.php) ---
|
||||||
|
|
||||||
|
func (r *Rooms) rating(ctx context.Context, x *proxy.Exchange) bool {
|
||||||
|
form := params(x)
|
||||||
|
n, ok := RoomNumber(form.Get("room_instance_id"))
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if score, err := strconv.Atoi(form.Get("score")); err == nil && x.Request.Method == "POST" {
|
||||||
|
if err := r.Hub.Call(ctx, "PUT", fmt.Sprintf("/api/v1/hosted-rooms/%d/rating", n), map[string]any{"score": score}, nil); err != nil {
|
||||||
|
x.Response = proxy.LocalResponse(200, "application/json", []byte(`{"result":"failure"}`))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
x.Response = proxy.LocalResponse(200, "application/json", []byte(`{"result":"success"}`))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
mine := 0
|
||||||
|
if rm, _, _, err := r.getRoom(ctx, n); err == nil {
|
||||||
|
mine = rm.MyRating
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(map[string]any{"score": mine, "result": "success"})
|
||||||
|
x.Response = proxy.LocalResponse(200, "application/json", b)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- occupancy (room_occupancy/<instance> queues in the room list) ---
|
||||||
|
|
||||||
|
// watch subscribes (or unsubscribes) a connection to a room's occupancy, and tells the hub which rooms to report.
|
||||||
|
func (r *Rooms) watch(c *proxy.IMQConn, n int64, queue string, on bool) []imq.Property {
|
||||||
|
r.mu.Lock()
|
||||||
|
if r.watched == nil {
|
||||||
|
r.watched = map[int64]map[*proxy.IMQConn]string{}
|
||||||
|
}
|
||||||
|
if on {
|
||||||
|
if r.watched[n] == nil {
|
||||||
|
r.watched[n] = map[*proxy.IMQConn]string{}
|
||||||
|
}
|
||||||
|
r.watched[n][c] = queue
|
||||||
|
} else if r.watched[n] != nil {
|
||||||
|
delete(r.watched[n], c)
|
||||||
|
if len(r.watched[n]) == 0 {
|
||||||
|
delete(r.watched, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var props []imq.Property
|
||||||
|
for key := range r.occupants[n] {
|
||||||
|
props = append(props, imq.Property{Key: key, Value: "1"})
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
r.sendWatch()
|
||||||
|
return props
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Rooms) sendWatch() {
|
||||||
|
r.mu.Lock()
|
||||||
|
rooms := []int64{}
|
||||||
|
for n := range r.watched {
|
||||||
|
rooms = append(rooms, n)
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
r.Hub.Send(map[string]any{"type": "room.watch", "rooms": rooms})
|
||||||
|
}
|
||||||
|
|
||||||
|
// occupancy gives watchers a room's occupants as state changes: a key per person (the client counts them).
|
||||||
|
func (r *Rooms) occupancy(n int64, participants []Participant) {
|
||||||
|
now := map[string]bool{}
|
||||||
|
for _, p := range participants {
|
||||||
|
now[strconv.FormatInt(p.ImvuID, 10)] = true
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
if r.occupants == nil {
|
||||||
|
r.occupants = map[int64]map[string]bool{}
|
||||||
|
}
|
||||||
|
before := r.occupants[n]
|
||||||
|
r.occupants[n] = now
|
||||||
|
watchers := map[*proxy.IMQConn]string{}
|
||||||
|
for c, q := range r.watched[n] {
|
||||||
|
watchers[c] = q
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
var props []imq.Property
|
||||||
|
for key := range now {
|
||||||
|
if !before[key] {
|
||||||
|
props = append(props, imq.Property{Key: key, Value: "1"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key := range before {
|
||||||
|
if !now[key] {
|
||||||
|
props = append(props, imq.Property{Key: key}) // empty: gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(props) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for c, queue := range watchers {
|
||||||
|
c.Send(imq.G2cStateChange, imq.StateChange(0, "admin", queue, "occupants", props, 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,624 @@
|
|||||||
|
// Package hub connects the proxy to T5DE's hub (git.t5de.dev/t5de/t5de-hub): signing in with a code the person
|
||||||
|
// enters on the hub's website (OAuth 2.0 device authorization, by the hub's Better Auth), and a live connection
|
||||||
|
// reporting which IMVU account the client is using, the community products it wears, and the IMVU chats it's in;
|
||||||
|
// the hub answers with the other T5DE users in those chats and what they wear (the room channel).
|
||||||
|
//
|
||||||
|
// Signing in gives a session token, kept encrypted for the Windows user and sent as a bearer token. The hub extends
|
||||||
|
// the session while it's used, and ends it when the person signs their computers out or is banned.
|
||||||
|
package hub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/textproto"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Item is a community product worn.
|
||||||
|
type Item struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Adult bool `json:"adult"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avatar is another T5DE user in a chat, and what they wear.
|
||||||
|
type Avatar struct {
|
||||||
|
IMVUID int64 `json:"imvu_id"`
|
||||||
|
Items []Item `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saved is the proxy's sign-in, in <data dir>/hub.json. The session token is encrypted for the Windows user.
|
||||||
|
type Saved struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Token string `json:"-"`
|
||||||
|
Protected []byte `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrSignedOut means there's no sign-in, or the hub ended it.
|
||||||
|
var ErrSignedOut = errors.New("not signed in to the hub")
|
||||||
|
|
||||||
|
func Load(path string) (*Saved, error) {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, ErrSignedOut
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var s Saved
|
||||||
|
if err := json.Unmarshal(b, &s); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tok, err := unprotect(s.Protected)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading the hub sign-in: %w", err)
|
||||||
|
}
|
||||||
|
s.Token = string(tok)
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Saved) Save(path string) error {
|
||||||
|
p, err := protect([]byte(s.Token))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.Protected = p
|
||||||
|
b, err := json.MarshalIndent(s, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientID is the proxy's client ID in device sign-in.
|
||||||
|
const ClientID = "t5de-proxy"
|
||||||
|
|
||||||
|
// UserAgent marks the proxy's requests; the hub counts "computers signed in" by it.
|
||||||
|
var UserAgent = "t5de-proxy"
|
||||||
|
|
||||||
|
func postJSON(ctx context.Context, endpoint string, body any, out any) (int, error) {
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(string(b)))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", UserAgent)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err := json.Unmarshal(data, out); err != nil {
|
||||||
|
return resp.StatusCode, fmt.Errorf("the hub answered %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignIn signs the proxy in: it gets a code from the hub, shows it (show gets the code and the page to enter it
|
||||||
|
// on), and waits until the person has entered it.
|
||||||
|
func SignIn(ctx context.Context, hubURL string, show func(code, page string)) (*Saved, error) {
|
||||||
|
hubURL = strings.TrimSuffix(hubURL, "/")
|
||||||
|
var device struct {
|
||||||
|
DeviceCode string `json:"device_code"`
|
||||||
|
UserCode string `json:"user_code"`
|
||||||
|
Complete string `json:"verification_uri_complete"`
|
||||||
|
Interval int `json:"interval"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
status, err := postJSON(ctx, hubURL+"/api/auth/device/code", map[string]string{"client_id": ClientID}, &device)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if device.DeviceCode == "" {
|
||||||
|
return nil, fmt.Errorf("the hub didn't give a sign-in code (%d)", status)
|
||||||
|
}
|
||||||
|
show(device.UserCode, device.Complete)
|
||||||
|
interval := time.Duration(max(device.Interval, 2)) * time.Second
|
||||||
|
deadline := time.Now().Add(time.Duration(device.ExpiresIn) * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(interval):
|
||||||
|
}
|
||||||
|
var t struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
_, err := postJSON(ctx, hubURL+"/api/auth/device/token", map[string]string{
|
||||||
|
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||||||
|
"device_code": device.DeviceCode,
|
||||||
|
"client_id": ClientID,
|
||||||
|
}, &t)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case t.AccessToken != "":
|
||||||
|
return &Saved{URL: hubURL, Token: t.AccessToken}, nil
|
||||||
|
case t.Error == "authorization_pending":
|
||||||
|
case t.Error == "slow_down":
|
||||||
|
interval += 5 * time.Second
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("sign-in failed: %s", t.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("the code expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client is the live connection to the hub.
|
||||||
|
type Client struct {
|
||||||
|
path string
|
||||||
|
log *log.Logger
|
||||||
|
|
||||||
|
// OnChat, if set, hears the other T5DE users in a chat the client is in, whenever that changes (and with none
|
||||||
|
// when the connection to the hub is lost).
|
||||||
|
OnChat func(chat string, avatars []Avatar)
|
||||||
|
// OnLibrary, if set, is called when connected and whenever the person's library changes.
|
||||||
|
OnLibrary func()
|
||||||
|
// OnConnect, if set, is called each time the connection to the hub is made (to send state again).
|
||||||
|
OnConnect func()
|
||||||
|
// OnRoom, if set, gets the hub's messages about hosted rooms ("room.*"), as sent.
|
||||||
|
OnRoom func(data []byte)
|
||||||
|
// OnDecor, if set, hears that an IMVU room's decorations changed (its instance ID).
|
||||||
|
OnDecor func(room string)
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
saved *Saved
|
||||||
|
imvuID int64
|
||||||
|
outfit []Item
|
||||||
|
chats map[string]string // chat ID -> room instance ("" for other chats)
|
||||||
|
wake chan struct{}
|
||||||
|
outbox chan []byte // messages sent as they come (Send), not state
|
||||||
|
}
|
||||||
|
|
||||||
|
// New makes a client for the sign-in saved at path.
|
||||||
|
func New(path string, saved *Saved, logger *log.Logger) *Client {
|
||||||
|
return &Client{path: path, saved: saved, log: logger, chats: map[string]string{}, wake: make(chan struct{}, 1),
|
||||||
|
outbox: make(chan []byte, 256)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join reports the client is in an IMVU chat (room: the room instance, for a room's chat).
|
||||||
|
func (c *Client) Join(chat, room string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
old, had := c.chats[chat]
|
||||||
|
c.chats[chat] = room
|
||||||
|
c.mu.Unlock()
|
||||||
|
if !had || old != room {
|
||||||
|
c.poke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave reports the client left an IMVU chat.
|
||||||
|
func (c *Client) Leave(chat string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
_, had := c.chats[chat]
|
||||||
|
delete(c.chats, chat)
|
||||||
|
c.mu.Unlock()
|
||||||
|
if had {
|
||||||
|
c.poke()
|
||||||
|
}
|
||||||
|
if c.OnChat != nil {
|
||||||
|
c.OnChat(chat, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session reports the IMVU account the client signed in as.
|
||||||
|
func (c *Client) Session(imvuID int64) {
|
||||||
|
c.mu.Lock()
|
||||||
|
changed := c.imvuID != imvuID
|
||||||
|
c.imvuID = imvuID
|
||||||
|
if changed {
|
||||||
|
c.outfit = nil
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
if changed {
|
||||||
|
c.poke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outfit reports the community products the account wears.
|
||||||
|
func (c *Client) Outfit(imvuID int64, items []Item) {
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.imvuID != imvuID {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.outfit = append([]Item(nil), items...)
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.poke()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send sends a message to the hub once (hosted rooms' messages); false if it can't be queued. Messages queued while
|
||||||
|
// disconnected are dropped on connecting (OnConnect sends what's still true).
|
||||||
|
func (c *Client) Send(v any) bool {
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case c.outbox <- b:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
c.log.Printf("hub: too many messages waiting; dropped one")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) poke() {
|
||||||
|
select {
|
||||||
|
case c.wake <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run keeps the connection up until ctx ends or the hub signs the proxy out.
|
||||||
|
func (c *Client) Run(ctx context.Context) {
|
||||||
|
backoff := time.Second
|
||||||
|
for ctx.Err() == nil {
|
||||||
|
start := time.Now()
|
||||||
|
err := c.connect(ctx)
|
||||||
|
if errors.Is(err, ErrSignedOut) {
|
||||||
|
c.log.Printf("hub: signed out (%v); run t5de-proxy signin again", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if time.Since(start) > time.Minute {
|
||||||
|
backoff = time.Second
|
||||||
|
}
|
||||||
|
c.log.Printf("hub: disconnected (%v); retrying in %s", err, backoff)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(backoff):
|
||||||
|
}
|
||||||
|
backoff = min(backoff*2, 2*time.Minute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) connect(ctx context.Context) error {
|
||||||
|
wsURL := strings.Replace(strings.Replace(c.saved.URL, "https://", "wss://", 1), "http://", "ws://", 1) + "/api/v1/realtime"
|
||||||
|
conn, resp, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: http.Header{
|
||||||
|
"Authorization": {"Bearer " + c.saved.Token}, "User-Agent": {UserAgent}}})
|
||||||
|
if resp != nil && (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) {
|
||||||
|
return fmt.Errorf("%w: the hub answered %d", ErrSignedOut, resp.StatusCode)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.CloseNow()
|
||||||
|
c.log.Printf("hub: connected to %s", c.saved.URL)
|
||||||
|
if c.OnLibrary != nil {
|
||||||
|
go c.OnLibrary()
|
||||||
|
}
|
||||||
|
for drained := false; !drained; {
|
||||||
|
select {
|
||||||
|
case <-c.outbox:
|
||||||
|
default:
|
||||||
|
drained = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
errs := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
_, data, err := conn.Read(ctx)
|
||||||
|
if err != nil {
|
||||||
|
// The hub closes with 4001 when the person signs their computers out or is banned.
|
||||||
|
if websocket.CloseStatus(err) == 4001 {
|
||||||
|
err = fmt.Errorf("%w: %v", ErrSignedOut, err)
|
||||||
|
}
|
||||||
|
errs <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var kind struct{ Type string }
|
||||||
|
if json.Unmarshal(data, &kind) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(kind.Type, "room.") {
|
||||||
|
// Hosted rooms' messages, whose fields differ (chat IDs are numbers).
|
||||||
|
if c.OnRoom != nil {
|
||||||
|
c.OnRoom(data)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Type, Message, Chat, Room string
|
||||||
|
Avatars []Avatar
|
||||||
|
}
|
||||||
|
if json.Unmarshal(data, &m) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch m.Type {
|
||||||
|
case "error":
|
||||||
|
c.log.Printf("hub: %s", m.Message)
|
||||||
|
case "decor":
|
||||||
|
if c.OnDecor != nil && m.Room != "" {
|
||||||
|
go c.OnDecor(m.Room)
|
||||||
|
}
|
||||||
|
case "library":
|
||||||
|
if c.OnLibrary != nil {
|
||||||
|
go c.OnLibrary()
|
||||||
|
}
|
||||||
|
case "chat":
|
||||||
|
c.mu.Lock()
|
||||||
|
_, in := c.chats[m.Chat]
|
||||||
|
c.mu.Unlock()
|
||||||
|
if in && c.OnChat != nil {
|
||||||
|
c.OnChat(m.Chat, m.Avatars)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
send := func(v any) error {
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
wctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return conn.Write(wctx, websocket.MessageText, b)
|
||||||
|
}
|
||||||
|
// Without the hub, nobody else's community products are known.
|
||||||
|
defer func() {
|
||||||
|
if c.OnChat == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
var chats []string
|
||||||
|
for chat := range c.chats {
|
||||||
|
chats = append(chats, chat)
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
for _, chat := range chats {
|
||||||
|
c.OnChat(chat, nil)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
sentID, sentOutfit := int64(-1), ""
|
||||||
|
sentChats := map[string]string{}
|
||||||
|
connected := false
|
||||||
|
for {
|
||||||
|
c.mu.Lock()
|
||||||
|
id, outfit := c.imvuID, append([]Item{}, c.outfit...) // a list, never null
|
||||||
|
chats := make(map[string]string, len(c.chats))
|
||||||
|
for k, v := range c.chats {
|
||||||
|
chats[k] = v
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
if id != sentID && id != 0 {
|
||||||
|
if err := send(map[string]any{"type": "session", "imvu_id": id}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sentID, sentOutfit = id, ""
|
||||||
|
}
|
||||||
|
if b, _ := json.Marshal(outfit); id != 0 && string(b) != sentOutfit {
|
||||||
|
if err := send(map[string]any{"type": "outfit", "items": outfit}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sentOutfit = string(b)
|
||||||
|
}
|
||||||
|
for chat, room := range chats {
|
||||||
|
if r, ok := sentChats[chat]; ok && r == room {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := map[string]any{"type": "join", "chat": chat}
|
||||||
|
if room != "" {
|
||||||
|
m["room"] = room
|
||||||
|
}
|
||||||
|
if err := send(m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if room != "" {
|
||||||
|
c.log.Printf("hub: in chat %s (room %s)", chat, room)
|
||||||
|
} else {
|
||||||
|
c.log.Printf("hub: in chat %s", chat)
|
||||||
|
}
|
||||||
|
sentChats[chat] = room
|
||||||
|
}
|
||||||
|
for chat := range sentChats {
|
||||||
|
if _, ok := chats[chat]; !ok {
|
||||||
|
if err := send(map[string]any{"type": "leave", "chat": chat}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.log.Printf("hub: left chat %s", chat)
|
||||||
|
delete(sentChats, chat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !connected {
|
||||||
|
// After the session (so the hub knows the IMVU account), say what's still true.
|
||||||
|
connected = true
|
||||||
|
if c.OnConnect != nil {
|
||||||
|
go c.OnConnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
conn.Close(websocket.StatusNormalClosure, "")
|
||||||
|
return ctx.Err()
|
||||||
|
case err := <-errs:
|
||||||
|
return err
|
||||||
|
case b := <-c.outbox:
|
||||||
|
wctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
err := conn.Write(wctx, websocket.MessageText, b)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case <-c.wake:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call makes a request to the hub's API as the signed-in person: path under the hub's URL, body (if any) sent as
|
||||||
|
// JSON, the answer decoded into out.
|
||||||
|
func (c *Client) Call(ctx context.Context, method, path string, body, out any) error {
|
||||||
|
var r io.Reader
|
||||||
|
if body != nil {
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
r = strings.NewReader(string(b))
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, c.saved.URL+path, r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.saved.Token)
|
||||||
|
req.Header.Set("User-Agent", UserAgent)
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||||
|
return answer(resp, data, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload sends a file to the hub as multipart form data (one field), as Call does JSON.
|
||||||
|
func (c *Client) Upload(ctx context.Context, method, path, field, filename, contentType string, file []byte, out any) error {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := multipart.NewWriter(&buf)
|
||||||
|
h := textproto.MIMEHeader{}
|
||||||
|
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name=%q; filename=%q`, field, filename))
|
||||||
|
h.Set("Content-Type", contentType)
|
||||||
|
part, err := w.CreatePart(h)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
part.Write(file)
|
||||||
|
w.Close()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, c.saved.URL+path, &buf)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.saved.Token)
|
||||||
|
req.Header.Set("User-Agent", UserAgent)
|
||||||
|
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||||
|
return answer(resp, data, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error is the hub refusing a call: its status, error code and message (shown to people as is).
|
||||||
|
type Error struct {
|
||||||
|
Status int
|
||||||
|
Code string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Error() string { return fmt.Sprintf("the hub answered %d %s", e.Status, e.Code) }
|
||||||
|
|
||||||
|
func answer(resp *http.Response, data []byte, out any) error {
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
var e struct{ Error, Message string }
|
||||||
|
json.Unmarshal(data, &e)
|
||||||
|
return &Error{Status: resp.StatusCode, Code: e.Error, Message: e.Message}
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileEntry is one of a revision's files.
|
||||||
|
type FileEntry struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files is the revision of a product the person uses, and where to get it (GET /api/v1/products/:id/files).
|
||||||
|
type Files struct {
|
||||||
|
Product string `json:"product"`
|
||||||
|
Revision int `json:"revision"`
|
||||||
|
Rating string `json:"rating"`
|
||||||
|
Parent int64 `json:"parent"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Creator string `json:"creator"`
|
||||||
|
Files []FileEntry `json:"files"`
|
||||||
|
Catalogs []string `json:"catalogs"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryEntry is a product in the person's library.
|
||||||
|
type LibraryEntry struct {
|
||||||
|
Product Product `json:"product"`
|
||||||
|
StayOn *int `json:"stayOn"`
|
||||||
|
AddedAt time.Time `json:"addedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Library is the person's library.
|
||||||
|
func (c *Client) Library(ctx context.Context) ([]LibraryEntry, error) {
|
||||||
|
var out struct{ Library []LibraryEntry }
|
||||||
|
err := c.Call(ctx, "GET", "/api/v1/library", nil, &out)
|
||||||
|
return out.Library, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductFiles is the revision of a product to load.
|
||||||
|
func (c *Client) ProductFiles(ctx context.Context, id string) (*Files, error) {
|
||||||
|
var out Files
|
||||||
|
if err := c.Call(ctx, "GET", "/api/v1/products/"+id+"/files", nil, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccessToken is a token for catalogs' adult files (an hour long).
|
||||||
|
func (c *Client) AccessToken(ctx context.Context) (string, error) {
|
||||||
|
var out struct{ Token string }
|
||||||
|
err := c.Call(ctx, "POST", "/api/v1/access", nil, &out)
|
||||||
|
return out.Token, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Product is a product as the hub lists it (GET /api/v1/products).
|
||||||
|
type Product struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Creator string `json:"creator"`
|
||||||
|
Rating string `json:"rating"`
|
||||||
|
Latest int `json:"latest"`
|
||||||
|
Parent int64 `json:"parent"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
Categories []Category `json:"categories"` // the parent's IMVU category path, top first
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category is an IMVU shop category.
|
||||||
|
type Category struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Products searches the hub's products (by name; all if query is empty), newest first.
|
||||||
|
func (c *Client) Products(ctx context.Context, query string) ([]Product, error) {
|
||||||
|
var out struct{ Products []Product }
|
||||||
|
err := c.Call(ctx, "GET", "/api/v1/products?limit=200&q="+url.QueryEscape(query), nil, &out)
|
||||||
|
return out.Products, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package hub
|
||||||
|
|
||||||
|
// Elsewhere (tests, tools) the token is stored as is: the client only runs on Windows.
|
||||||
|
func protect(data []byte) ([]byte, error) { return data, nil }
|
||||||
|
func unprotect(data []byte) ([]byte, error) { return data, nil }
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package hub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// protect encrypts the refresh token for the Windows user (DPAPI), so other accounts on the machine can't use it.
|
||||||
|
func protect(data []byte) ([]byte, error) {
|
||||||
|
in := windows.DataBlob{Size: uint32(len(data)), Data: unsafe.SliceData(data)}
|
||||||
|
var out windows.DataBlob
|
||||||
|
if err := windows.CryptProtectData(&in, windows.StringToUTF16Ptr("T5DE hub sign-in"), nil, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer windows.LocalFree(windows.Handle(unsafe.Pointer(out.Data)))
|
||||||
|
return append([]byte(nil), unsafe.Slice(out.Data, out.Size)...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func unprotect(data []byte) ([]byte, error) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
in := windows.DataBlob{Size: uint32(len(data)), Data: unsafe.SliceData(data)}
|
||||||
|
var out windows.DataBlob
|
||||||
|
if err := windows.CryptUnprotectData(&in, nil, nil, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer windows.LocalFree(windows.Handle(unsafe.Pointer(out.Data)))
|
||||||
|
return append([]byte(nil), unsafe.Slice(out.Data, out.Size)...), nil
|
||||||
|
}
|
||||||
@@ -40,10 +40,11 @@ func TypeName(t uint64) string {
|
|||||||
return fmt.Sprintf("type %d", t)
|
return fmt.Sprintf("type %d", t)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Frame is one message: its type and the size of its data.
|
// Frame is one message: its type and data (Size is len(Data)).
|
||||||
type Frame struct {
|
type Frame struct {
|
||||||
Type uint64
|
Type uint64
|
||||||
Size int
|
Size int
|
||||||
|
Data []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrMalformed means the stream isn't IMQ framing; a Reader stops decoding after it.
|
// ErrMalformed means the stream isn't IMQ framing; a Reader stops decoding after it.
|
||||||
@@ -76,6 +77,12 @@ func (r *Reader) Write(data []byte) ([]Frame, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Next reads the frame at the start of b: the frame and its length in b, a length of 0 if b holds only part of it,
|
||||||
|
// or ErrMalformed.
|
||||||
|
func Next(b []byte) (Frame, int, error) {
|
||||||
|
return parse(b)
|
||||||
|
}
|
||||||
|
|
||||||
// maxFrame bounds a frame's size, so a stream that isn't IMQ isn't buffered without end.
|
// maxFrame bounds a frame's size, so a stream that isn't IMQ isn't buffered without end.
|
||||||
const maxFrame = 16 << 20
|
const maxFrame = 16 << 20
|
||||||
|
|
||||||
@@ -116,6 +123,7 @@ func parse(b []byte) (Frame, int, error) {
|
|||||||
return f, 0, nil
|
return f, 0, nil
|
||||||
}
|
}
|
||||||
f.Size = int(size)
|
f.Size = int(size)
|
||||||
|
f.Data = b[i : i+int(size) : i+int(size)]
|
||||||
return f, i + int(size), nil
|
return f, i + int(size), nil
|
||||||
default:
|
default:
|
||||||
return f, 0, ErrMalformed
|
return f, 0, ErrMalformed
|
||||||
|
|||||||
@@ -36,12 +36,12 @@ func TestReaderSplitsFrames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
got = append(got, frames...)
|
got = append(got, frames...)
|
||||||
}
|
}
|
||||||
want := []Frame{{1302, 5}, {3103, 300}, {1301, 0}}
|
want := []Frame{{1302, 5, []byte("hello")}, {3103, 300, bytes.Repeat([]byte{1}, 300)}, {1301, 0, nil}}
|
||||||
if len(got) != len(want) {
|
if len(got) != len(want) {
|
||||||
t.Fatalf("got %v, want %v", got, want)
|
t.Fatalf("got %v, want %v", got, want)
|
||||||
}
|
}
|
||||||
for i := range want {
|
for i := range want {
|
||||||
if got[i] != want[i] {
|
if got[i].Type != want[i].Type || got[i].Size != want[i].Size || !bytes.Equal(got[i].Data, want[i].Data) {
|
||||||
t.Fatalf("got %v, want %v", got, want)
|
t.Fatalf("got %v, want %v", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,3 +63,51 @@ func TestTypeName(t *testing.T) {
|
|||||||
t.Fatal(TypeName(3104), TypeName(7))
|
t.Fatal(TypeName(3104), TypeName(7))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSendMessageRoundTrip(t *testing.T) {
|
||||||
|
m := SendMessage{OpID: 300, Queue: "/chat/1", Mount: "messages", Message: []byte(`{"message": "*use 80 1"}`)}
|
||||||
|
data := m.Marshal()
|
||||||
|
got, err := ParseSendMessage(data)
|
||||||
|
if err != nil || got.OpID != 300 || got.Queue != "/chat/1" || got.Mount != "messages" || string(got.Message) != string(m.Message) {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
// A frame of it reads back as one frame with the same data.
|
||||||
|
var r Reader
|
||||||
|
frames, err := r.Write(EncodeFrame(C2gSendMessage, data))
|
||||||
|
if err != nil || len(frames) != 1 || frames[0].Type != C2gSendMessage || !bytes.Equal(frames[0].Data, data) {
|
||||||
|
t.Fatalf("%v %v", frames, err)
|
||||||
|
}
|
||||||
|
if _, err := ParseSendMessage(Result(1, 0)); err == nil {
|
||||||
|
t.Fatal("a G2cResult parsed as a C2gSendMessage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscriptionOps(t *testing.T) {
|
||||||
|
// A subscription with a plain queue, and one with an op ID and a property (field 3), which survives re-encoding.
|
||||||
|
withProps := appendBytesField(appendVarintField(appendBytesField(nil, 1, []byte("/chat/9")), 2, 7), 3, []byte("x"))
|
||||||
|
data := appendBytesField(appendBytesField(nil, 1, []byte("/user/1")), 2, withProps)
|
||||||
|
s, err := ParseSubscriptionOps(data)
|
||||||
|
if err != nil || len(s.Queues) != 1 || len(s.WithResults) != 1 || s.WithResults[0].Queue != "/chat/9" || s.WithResults[0].OpID != 7 {
|
||||||
|
t.Fatalf("%+v %v", s, err)
|
||||||
|
}
|
||||||
|
if string(s.Marshal()) != string(data) {
|
||||||
|
t.Fatal("re-encoded differently")
|
||||||
|
}
|
||||||
|
if all := s.All(); len(all) != 2 || all[1] != "/chat/9" {
|
||||||
|
t.Fatalf("all %v", all)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFloodgates(t *testing.T) {
|
||||||
|
state := func(q string, seq, op uint64) []byte {
|
||||||
|
return appendVarintField(appendVarintField(appendBytesField(nil, 1, []byte(q)), 2, seq), 3, op)
|
||||||
|
}
|
||||||
|
data := appendBytesField(appendBytesField(nil, 1, state("/user/1", 38, 19)), 1, state("/chat/2100000001", 29, 20))
|
||||||
|
states, err := ParseFloodgates(data)
|
||||||
|
if err != nil || len(states) != 2 || states[1].Queue != "/chat/2100000001" || states[1].OpID != 20 {
|
||||||
|
t.Fatalf("%+v %v", states, err)
|
||||||
|
}
|
||||||
|
if string(MarshalFloodgates(states)) != string(data) || string(MarshalFloodgates(states[:1])) != string(appendBytesField(nil, 1, state("/user/1", 38, 19))) {
|
||||||
|
t.Fatal("re-encoded differently")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
package imq
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// Message types the proxy reads or writes.
|
||||||
|
const (
|
||||||
|
C2gPing = 1301
|
||||||
|
C2gSendMessage = 1304
|
||||||
|
C2gOpenFloodgates = 1306
|
||||||
|
C2gUnsubscribe = 1308
|
||||||
|
C2gSubscribe = 1309
|
||||||
|
G2cResult = 3102
|
||||||
|
G2cSendMessage = 3104
|
||||||
|
G2cStateChange = 3105
|
||||||
|
G2cJoinedQueue = 3106
|
||||||
|
G2cLeftQueue = 3107
|
||||||
|
G2cCreateMount = 3108
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mount types (G2cCreateMount).
|
||||||
|
const (
|
||||||
|
MessageMount = 1
|
||||||
|
StateMount = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseSubscription reads a C2gSubscribe's or C2gUnsubscribe's data: the queues (listed plainly, or with an op ID
|
||||||
|
// for a result).
|
||||||
|
func ParseSubscription(data []byte) ([]string, error) {
|
||||||
|
fs, err := fields(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var queues []string
|
||||||
|
for _, f := range fs {
|
||||||
|
switch {
|
||||||
|
case f.num == 1 && f.isLen:
|
||||||
|
queues = append(queues, string(f.bytes))
|
||||||
|
case f.num == 2 && f.isLen:
|
||||||
|
sub, err := fields(f.bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, sf := range sub {
|
||||||
|
if sf.num == 1 && sf.isLen {
|
||||||
|
queues = append(queues, string(sf.bytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, errors.New("imq: unexpected field in a subscription")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return queues, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delivered is a G2cSendMessage: a message the gateway delivers to the client, from a user (user_id).
|
||||||
|
type Delivered struct {
|
||||||
|
UserID string
|
||||||
|
Queue string
|
||||||
|
Mount string
|
||||||
|
Message []byte
|
||||||
|
Sequence uint64 // 0: none (the client only tracks sequences it's given)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseDelivered reads a G2cSendMessage's data.
|
||||||
|
func ParseDelivered(data []byte) (Delivered, error) {
|
||||||
|
fs, err := fields(data)
|
||||||
|
if err != nil {
|
||||||
|
return Delivered{}, err
|
||||||
|
}
|
||||||
|
var m Delivered
|
||||||
|
seen := 0
|
||||||
|
for _, f := range fs {
|
||||||
|
switch {
|
||||||
|
case f.num == 1 && f.isLen:
|
||||||
|
m.UserID, seen = string(f.bytes), 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
|
||||||
|
case f.num == 5 && !f.isLen:
|
||||||
|
m.Sequence = f.varint
|
||||||
|
default:
|
||||||
|
return Delivered{}, errors.New("imq: unexpected field in G2cSendMessage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if seen != 15 {
|
||||||
|
return Delivered{}, errors.New("imq: G2cSendMessage is missing fields")
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal encodes the message's data, fields in the gateway's order.
|
||||||
|
func (m Delivered) Marshal() []byte {
|
||||||
|
var b []byte
|
||||||
|
b = appendBytesField(b, 1, []byte(m.UserID))
|
||||||
|
b = appendBytesField(b, 2, []byte(m.Queue))
|
||||||
|
b = appendBytesField(b, 3, []byte(m.Mount))
|
||||||
|
b = appendBytesField(b, 4, m.Message)
|
||||||
|
if m.Sequence != 0 {
|
||||||
|
b = appendVarintField(b, 5, m.Sequence)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueueOp is a queue subscribed to with an operation ID, for a G2cResult.
|
||||||
|
type QueueOp struct {
|
||||||
|
Queue string
|
||||||
|
OpID uint64
|
||||||
|
raw []byte // as the client sent it (with any other fields, such as properties)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscription is a C2gSubscribe's or C2gUnsubscribe's data: queues listed plainly, and with operation IDs.
|
||||||
|
type Subscription struct {
|
||||||
|
Queues []string
|
||||||
|
WithResults []QueueOp
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseSubscriptionOps reads a C2gSubscribe's or C2gUnsubscribe's data, keeping operation IDs.
|
||||||
|
func ParseSubscriptionOps(data []byte) (Subscription, error) {
|
||||||
|
fs, err := fields(data)
|
||||||
|
if err != nil {
|
||||||
|
return Subscription{}, err
|
||||||
|
}
|
||||||
|
var s Subscription
|
||||||
|
for _, f := range fs {
|
||||||
|
switch {
|
||||||
|
case f.num == 1 && f.isLen:
|
||||||
|
s.Queues = append(s.Queues, string(f.bytes))
|
||||||
|
case f.num == 2 && f.isLen:
|
||||||
|
sub, err := fields(f.bytes)
|
||||||
|
if err != nil {
|
||||||
|
return Subscription{}, err
|
||||||
|
}
|
||||||
|
q := QueueOp{raw: f.bytes}
|
||||||
|
for _, sf := range sub {
|
||||||
|
switch {
|
||||||
|
case sf.num == 1 && sf.isLen:
|
||||||
|
q.Queue = string(sf.bytes)
|
||||||
|
case sf.num == 2 && !sf.isLen:
|
||||||
|
q.OpID = sf.varint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.WithResults = append(s.WithResults, q)
|
||||||
|
default:
|
||||||
|
return Subscription{}, errors.New("imq: unexpected field in a subscription")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// All is every queue in the subscription.
|
||||||
|
func (s Subscription) All() []string {
|
||||||
|
out := append([]string(nil), s.Queues...)
|
||||||
|
for _, q := range s.WithResults {
|
||||||
|
out = append(out, q.Queue)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty reports whether it names no queues.
|
||||||
|
func (s Subscription) Empty() bool { return len(s.Queues) == 0 && len(s.WithResults) == 0 }
|
||||||
|
|
||||||
|
// Marshal encodes the subscription's data.
|
||||||
|
func (s Subscription) Marshal() []byte {
|
||||||
|
var b []byte
|
||||||
|
for _, q := range s.Queues {
|
||||||
|
b = appendBytesField(b, 1, []byte(q))
|
||||||
|
}
|
||||||
|
for _, q := range s.WithResults {
|
||||||
|
raw := q.raw
|
||||||
|
if raw == nil {
|
||||||
|
raw = appendVarintField(appendBytesField(nil, 1, []byte(q.Queue)), 2, q.OpID)
|
||||||
|
}
|
||||||
|
b = appendBytesField(b, 2, raw)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Property is a mount's property: a key and a value.
|
||||||
|
type Property struct{ Key, Value string }
|
||||||
|
|
||||||
|
func appendProperties(b []byte, num uint64, props []Property) []byte {
|
||||||
|
for _, p := range props {
|
||||||
|
b = appendBytesField(b, num, appendBytesField(appendBytesField(nil, 1, []byte(p.Key)), 2, []byte(p.Value)))
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// JoinedQueue encodes a G2cJoinedQueue's (or G2cLeftQueue's) data: a user joined (left) a queue.
|
||||||
|
func JoinedQueue(userID, queue string, sequence uint64) []byte {
|
||||||
|
return appendVarintField(appendBytesField(appendBytesField(nil, 1, []byte(userID)), 2, []byte(queue)), 3, sequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMount encodes a G2cCreateMount's data: a queue's mount, of type MessageMount or StateMount.
|
||||||
|
func CreateMount(queue, mount string, mountType uint64, props []Property, sequence uint64) []byte {
|
||||||
|
b := appendVarintField(appendBytesField(appendBytesField(nil, 1, []byte(queue)), 2, []byte(mount)), 3, mountType)
|
||||||
|
return appendVarintField(appendProperties(b, 4, props), 5, sequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StateChange encodes a G2cStateChange's data: a state mount's properties changed (an empty value removes one).
|
||||||
|
func StateChange(opID uint64, userID, queue, mount string, props []Property, sequence uint64) []byte {
|
||||||
|
b := appendVarintField(nil, 1, opID)
|
||||||
|
b = appendBytesField(appendBytesField(appendBytesField(b, 2, []byte(userID)), 3, []byte(queue)), 4, []byte(mount))
|
||||||
|
return appendVarintField(appendProperties(b, 5, props), 6, sequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFloodgates reads a C2gOpenFloodgates's data (after reconnecting, the queues to resume): each queue's state
|
||||||
|
// as a QueueOp (its op ID for a result), keeping the entry as sent.
|
||||||
|
func ParseFloodgates(data []byte) ([]QueueOp, error) {
|
||||||
|
fs, err := fields(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []QueueOp
|
||||||
|
for _, f := range fs {
|
||||||
|
if f.num != 1 || !f.isLen {
|
||||||
|
return nil, errors.New("imq: unexpected field in C2gOpenFloodgates")
|
||||||
|
}
|
||||||
|
sub, err := fields(f.bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
q := QueueOp{raw: f.bytes}
|
||||||
|
for _, sf := range sub {
|
||||||
|
switch {
|
||||||
|
case sf.num == 1 && sf.isLen:
|
||||||
|
q.Queue = string(sf.bytes)
|
||||||
|
case sf.num == 3 && !sf.isLen:
|
||||||
|
q.OpID = sf.varint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, q)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalFloodgates encodes a C2gOpenFloodgates's data from queue states read by ParseFloodgates.
|
||||||
|
func MarshalFloodgates(states []QueueOp) []byte {
|
||||||
|
var b []byte
|
||||||
|
for _, q := range states {
|
||||||
|
b = appendBytesField(b, 1, q.raw)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package products
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"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, ok := parseID(f)
|
||||||
|
if ok && id == bodyProduct && fields[0] == "*use" {
|
||||||
|
r.outfit = true
|
||||||
|
}
|
||||||
|
if ok && isCommunity(id) {
|
||||||
|
r.products = append(r.products, id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
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,689 @@
|
|||||||
|
package products
|
||||||
|
|
||||||
|
// Decorations: community furniture (and poses, which are furniture) placed in IMVU rooms.
|
||||||
|
//
|
||||||
|
// IMVU keeps the room without them: the proxy takes community products out of the room its owner saves
|
||||||
|
// (test.setRoomState) and broadcasts ("*imvu:setRoomState <scene>" in the room's chat), and stores them on the hub
|
||||||
|
// for that room (hub products only; a local pack's can't be shared). When anyone with T5DE loads the room
|
||||||
|
// (test.getRoomState) or gets its owner's broadcast, the proxy adds the room's decorations back, as local IDs. The
|
||||||
|
// hub tells everyone in the room when its owner changes them ("decor"), and the proxy gives the client the room
|
||||||
|
// again with the new ones.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/zlib"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/imq"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/xmlrpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DecorItem is a decoration: a furniture instance ID, a hub product ID, and the placement, as IMVU's
|
||||||
|
// room_contents has them (as a JSON array).
|
||||||
|
type DecorItem struct {
|
||||||
|
Furni int64
|
||||||
|
Product string
|
||||||
|
Placement string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d DecorItem) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal([]any{d.Furni, d.Product, d.Placement})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DecorItem) UnmarshalJSON(b []byte) error {
|
||||||
|
var a []json.RawMessage
|
||||||
|
if err := json.Unmarshal(b, &a); err != nil || len(a) != 3 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
json.Unmarshal(a[0], &d.Furni)
|
||||||
|
json.Unmarshal(a[1], &d.Product)
|
||||||
|
return json.Unmarshal(a[2], &d.Placement)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decorations is the proxy's view of rooms' decorations, under its own lock.
|
||||||
|
type decorations struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
rooms map[string]decorEntry // room instance -> decorations
|
||||||
|
base map[string][]any // room instance -> its contents as IMVU last had them (for giving the room again)
|
||||||
|
info map[string]any // room instance -> its room_info, as last seen
|
||||||
|
}
|
||||||
|
|
||||||
|
type decorEntry struct {
|
||||||
|
items []DecorItem
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// decorFor is how long decorations are used before being asked for again (the hub also says when they change).
|
||||||
|
const decorFor = 5 * time.Minute
|
||||||
|
|
||||||
|
func (d *decorations) get(instance string) ([]DecorItem, bool) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
e, ok := d.rooms[instance]
|
||||||
|
if !ok || time.Since(e.at) > decorFor {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return e.items, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// last is the room's decorations as last known, however old (a broadcast can't wait for the hub).
|
||||||
|
func (d *decorations) last(instance string) ([]DecorItem, bool) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
e, ok := d.rooms[instance]
|
||||||
|
return e.items, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *decorations) set(instance string, items []DecorItem) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
if d.rooms == nil {
|
||||||
|
d.rooms = map[string]decorEntry{}
|
||||||
|
}
|
||||||
|
d.rooms[instance] = decorEntry{items, time.Now()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *decorations) setBase(instance string, contents []any, info any) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
if d.base == nil {
|
||||||
|
d.base, d.info = map[string][]any{}, map[string]any{}
|
||||||
|
}
|
||||||
|
d.base[instance] = contents
|
||||||
|
if info != nil {
|
||||||
|
d.info[instance] = info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *decorations) getBase(instance string) ([]any, any) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
return d.base[instance], d.info[instance]
|
||||||
|
}
|
||||||
|
|
||||||
|
// infoMap is a room_info as JSON for a scene (XML-RPC structs become objects).
|
||||||
|
func infoMap(v any) any {
|
||||||
|
s, ok := v.(xmlrpc.Struct)
|
||||||
|
if !ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
m := map[string]any{}
|
||||||
|
for _, mem := range s {
|
||||||
|
m[mem.Name] = mem.Value
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// roomOwner is the owner of an IMVU room instance ("<owner>-<n>").
|
||||||
|
func roomOwner(instance string) int64 {
|
||||||
|
owner, _, ok := strings.Cut(instance, "-")
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
id, _ := strconv.ParseInt(owner, 10, 64)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// isHostedInstance reports whether a room is hosted by the hub (its scene is the hosted package's).
|
||||||
|
func isHostedInstance(instance string) bool {
|
||||||
|
_, number, ok := strings.Cut(instance, "-")
|
||||||
|
n, err := strconv.ParseInt(number, 10, 64)
|
||||||
|
return ok && err == nil && n > hostedBase
|
||||||
|
}
|
||||||
|
|
||||||
|
// decorFetch gets a room's decorations, from memory or the hub.
|
||||||
|
func (p *Products) decorFetch(ctx context.Context, instance string) []DecorItem {
|
||||||
|
if items, ok := p.decor.get(instance); ok {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
return p.decorRefresh(ctx, instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decorRefresh asks the hub for a room's decorations; what was known stays until the answer replaces it.
|
||||||
|
func (p *Products) decorRefresh(ctx context.Context, instance string) []DecorItem {
|
||||||
|
if p.Decorations == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
items, err := p.Decorations(ctx, instance)
|
||||||
|
if err != nil {
|
||||||
|
p.logf("decor: %s: %v", instance, err)
|
||||||
|
old, _ := p.decor.last(instance)
|
||||||
|
return old
|
||||||
|
}
|
||||||
|
p.decor.set(instance, items)
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
// decorLocals is decorations as room_contents rows with local IDs (asking for products that aren't here yet).
|
||||||
|
func (p *Products) decorLocals(items []DecorItem) []any {
|
||||||
|
var rows []any
|
||||||
|
for _, it := range items {
|
||||||
|
local, ok := p.LocalID(it.Product)
|
||||||
|
if !ok {
|
||||||
|
if p.Want != nil {
|
||||||
|
p.Want(it.Product)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rows = append(rows, []any{it.Furni, local, swapToken(it.Placement, it.Product, strconv.FormatInt(local, 10))})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// swapToken replaces a whole space-separated token of a placement: the placement names its own product, which is
|
||||||
|
// stored as the hub's product ID and given to the client as its local ID.
|
||||||
|
func swapToken(placement, from, to string) string {
|
||||||
|
fields := strings.Split(placement, " ")
|
||||||
|
for i, f := range fields {
|
||||||
|
if f == from {
|
||||||
|
fields[i] = to
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(fields, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitCommunity takes community products out of room_contents rows: the rest, and the community ones as
|
||||||
|
// decorations (hub products; others are dropped).
|
||||||
|
func (p *Products) splitCommunity(rows []any) (rest []any, items []DecorItem, stripped int) {
|
||||||
|
for _, row := range rows {
|
||||||
|
r, ok := row.([]any)
|
||||||
|
if !ok || len(r) < 3 {
|
||||||
|
rest = append(rest, row)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
product, ok := numberOf(r[1])
|
||||||
|
if !ok || !IsCommunity(product) {
|
||||||
|
rest = append(rest, row)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stripped++
|
||||||
|
id, ok := p.state.productID(product)
|
||||||
|
if !ok || !p.inHubDir(id) {
|
||||||
|
p.logf("decor: a room has %d, which isn't a hub product; it can't be shared", product)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
furni, _ := numberOf(r[0])
|
||||||
|
placement, _ := r[2].(string)
|
||||||
|
items = append(items, DecorItem{furni, id, swapToken(placement, strconv.FormatInt(product, 10), id)})
|
||||||
|
}
|
||||||
|
return rest, items, stripped
|
||||||
|
}
|
||||||
|
|
||||||
|
func numberOf(v any) (int64, bool) {
|
||||||
|
switch v := v.(type) {
|
||||||
|
case int64:
|
||||||
|
return v, true
|
||||||
|
case float64:
|
||||||
|
return int64(v), v == float64(int64(v))
|
||||||
|
case json.Number:
|
||||||
|
n, err := v.Int64()
|
||||||
|
return n, err == nil
|
||||||
|
case string:
|
||||||
|
return parseID(v)
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveDecor stores a room's decorations on the hub, if the client is its owner and they changed.
|
||||||
|
func (p *Products) saveDecor(instance string, items []DecorItem) {
|
||||||
|
if roomOwner(instance) != p.User() || p.SaveDecorations == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
old, known := p.decor.get(instance)
|
||||||
|
if known && sameDecor(old, items) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.decor.set(instance, items)
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := p.SaveDecorations(ctx, instance, items); err != nil {
|
||||||
|
p.logf("decor: saving %s: %v", instance, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.logf("decor: %d community products in %s, kept on T5DE's hub", len(items), instance)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameDecor(a, b []DecorItem) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- XML-RPC: saving and loading rooms ---
|
||||||
|
|
||||||
|
func (p *Products) interceptSetRoomState(x *proxy.Exchange, call *xmlrpc.Call) error {
|
||||||
|
args, ok := call.Params[0].(xmlrpc.Struct)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
instance := member(args, "room_instance_id")
|
||||||
|
if instance == "" || isHostedInstance(instance) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v, _ := args.Get("room_contents")
|
||||||
|
rows, ok := v.([]xmlrpc.Value)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rest, items, stripped := p.splitCommunity(rows)
|
||||||
|
_, hadDecor := p.decor.get(instance)
|
||||||
|
if stripped > 0 || hadDecor {
|
||||||
|
p.saveDecor(instance, items)
|
||||||
|
}
|
||||||
|
if stripped == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if rest == nil {
|
||||||
|
rest = []xmlrpc.Value{}
|
||||||
|
}
|
||||||
|
changed := make(xmlrpc.Struct, len(args))
|
||||||
|
copy(changed, args)
|
||||||
|
for i := range changed {
|
||||||
|
if changed[i].Name == "room_contents" {
|
||||||
|
changed[i].Value = rest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.logf("decor: kept %d community products out of %s saved to IMVU", stripped, instance)
|
||||||
|
return p.replaceBody(x, &xmlrpc.Call{Method: call.Method, Params: []xmlrpc.Value{changed}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Products) interceptGetRoomState(x *proxy.Exchange, call *xmlrpc.Call) error {
|
||||||
|
args, ok := call.Params[0].(xmlrpc.Struct)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
instance := member(args, "room_instance_id")
|
||||||
|
if instance == "" || isHostedInstance(instance) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Asked of the hub while IMVU is asked about the room: no added wait.
|
||||||
|
got := make(chan []DecorItem, 1)
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
got <- p.decorFetch(ctx, instance)
|
||||||
|
}()
|
||||||
|
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, nil
|
||||||
|
}
|
||||||
|
state, _ := r.Params[0].(xmlrpc.Struct)
|
||||||
|
roomState, _ := state.Get("room_state")
|
||||||
|
rs, ok := roomState.(xmlrpc.Struct)
|
||||||
|
if !ok {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
v, _ := rs.Get("room_contents")
|
||||||
|
rows, _ := v.([]xmlrpc.Value)
|
||||||
|
info, _ := rs.Get("room_info")
|
||||||
|
p.decor.setBase(instance, rows, infoMap(info))
|
||||||
|
var items []DecorItem
|
||||||
|
select {
|
||||||
|
case items = <-got:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
}
|
||||||
|
extra := p.decorLocals(items)
|
||||||
|
if len(extra) == 0 {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
for i := range rs {
|
||||||
|
if rs[i].Name == "room_contents" {
|
||||||
|
rs[i].Value = append(append([]xmlrpc.Value{}, rows...), extra...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.Marshal(), nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// member is a struct member as a string (strings and numbers).
|
||||||
|
func member(s xmlrpc.Struct, name string) string {
|
||||||
|
v, ok := s.Get(name)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch v := v.(type) {
|
||||||
|
case string:
|
||||||
|
return v
|
||||||
|
case int64:
|
||||||
|
return strconv.FormatInt(v, 10)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the owner's broadcast in the room's chat ---
|
||||||
|
|
||||||
|
const setRoomStateCommand = "*imvu:setRoomState "
|
||||||
|
|
||||||
|
// decodeScene reads a broadcast scene: "c" and base64 of zlib-compressed JSON (the client's
|
||||||
|
// RoomState.encodeRoomState).
|
||||||
|
func decodeScene(encoded string) (map[string]any, bool) {
|
||||||
|
encoded = strings.TrimLeft(encoded, " \t\r\n")
|
||||||
|
if !strings.HasPrefix(encoded, "c") {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(strings.Map(func(r rune) rune {
|
||||||
|
if r == '\n' || r == '\r' || r == ' ' {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, encoded[1:]))
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
zr, err := zlib.NewReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
b, err := io.ReadAll(io.LimitReader(zr, 4<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
d := json.NewDecoder(bytes.NewReader(b))
|
||||||
|
d.UseNumber()
|
||||||
|
var scene map[string]any
|
||||||
|
if d.Decode(&scene) != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return scene, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeScene writes a scene as the client does (base64 in lines of 76, each ending in a newline).
|
||||||
|
func encodeScene(scene map[string]any) string {
|
||||||
|
b, _ := json.Marshal(scene)
|
||||||
|
var z bytes.Buffer
|
||||||
|
w := zlib.NewWriter(&z)
|
||||||
|
w.Write(b)
|
||||||
|
w.Close()
|
||||||
|
s := base64.StdEncoding.EncodeToString(z.Bytes())
|
||||||
|
var out strings.Builder
|
||||||
|
out.WriteString("c")
|
||||||
|
for len(s) > 76 {
|
||||||
|
out.WriteString(s[:76])
|
||||||
|
out.WriteString("\n")
|
||||||
|
s = s[76:]
|
||||||
|
}
|
||||||
|
out.WriteString(s)
|
||||||
|
out.WriteString("\n")
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// sceneContents is the scene's room_contents and where it is (at the top, or under room_state).
|
||||||
|
func sceneContents(scene map[string]any) (map[string]any, []any) {
|
||||||
|
holder := scene
|
||||||
|
if rs, ok := scene["room_state"].(map[string]any); ok {
|
||||||
|
holder = rs
|
||||||
|
}
|
||||||
|
rows, _ := holder["room_contents"].([]any)
|
||||||
|
return holder, rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func sceneInstance(scene map[string]any) string {
|
||||||
|
holder, _ := sceneContents(scene)
|
||||||
|
info, _ := holder["room_info"].(map[string]any)
|
||||||
|
switch v := info["room_instance_id"].(type) {
|
||||||
|
case string:
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// outgoingScene takes community products out of the owner's broadcast (keeping them as the room's decorations).
|
||||||
|
func (p *Products) outgoingScene(m *chatMessage) ([]byte, bool) {
|
||||||
|
scene, ok := decodeScene(strings.TrimPrefix(m.text, setRoomStateCommand))
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
holder, rows := sceneContents(scene)
|
||||||
|
instance := sceneInstance(scene)
|
||||||
|
if isHostedInstance(instance) {
|
||||||
|
// A hosted room's scene goes only to T5DE users (through the hub): its community products travel as the
|
||||||
|
// hub's product IDs, which each proxy turns into its own local IDs.
|
||||||
|
out, changed := p.globalScene(rows)
|
||||||
|
if !changed {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
holder["room_contents"] = out
|
||||||
|
return m.withText(setRoomStateCommand + encodeScene(scene)), true
|
||||||
|
}
|
||||||
|
rest, items, stripped := p.splitCommunity(rows)
|
||||||
|
_, hadDecor := p.decor.get(instance)
|
||||||
|
if instance != "" && !isHostedInstance(instance) && (stripped > 0 || hadDecor) {
|
||||||
|
p.saveDecor(instance, items)
|
||||||
|
}
|
||||||
|
if instance != "" {
|
||||||
|
p.decor.setBase(instance, rest, holder["room_info"])
|
||||||
|
}
|
||||||
|
if stripped == 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if rest == nil {
|
||||||
|
rest = []any{}
|
||||||
|
}
|
||||||
|
holder["room_contents"] = rest
|
||||||
|
p.logf("decor: kept %d community products out of a room broadcast", stripped)
|
||||||
|
return m.withText(setRoomStateCommand + encodeScene(scene)), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// incomingControlScene adds the room's decorations to IMVU's server's {"command": "SetRoomState", "state": scene}
|
||||||
|
// on a chat's control mount.
|
||||||
|
func (p *Products) incomingControlScene(message []byte) ([]byte, bool) {
|
||||||
|
if !bytes.Contains(message, []byte(`"SetRoomState"`)) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if json.Unmarshal(message, &fields) != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
var command, state string
|
||||||
|
if json.Unmarshal(fields["command"], &command) != nil || command != "SetRoomState" || json.Unmarshal(fields["state"], &state) != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
scene, ok := decodeScene(state)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
encoded, changed := p.decorateScene(scene)
|
||||||
|
if !changed {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
fields["state"], _ = json.Marshal(encoded)
|
||||||
|
out, err := json.Marshal(fields)
|
||||||
|
return out, err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// outgoingControlScene takes community products out of a SetRoomState the client sends on a control mount (it
|
||||||
|
// isn't known to, but nothing naming them goes to IMVU).
|
||||||
|
func (p *Products) outgoingControlScene(message []byte) ([]byte, bool) {
|
||||||
|
if !bytes.Contains(message, []byte(`"SetRoomState"`)) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
var state string
|
||||||
|
if json.Unmarshal(message, &fields) != nil || json.Unmarshal(fields["state"], &state) != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
scene, ok := decodeScene(state)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
holder, rows := sceneContents(scene)
|
||||||
|
rest, _, stripped := p.splitCommunity(rows)
|
||||||
|
if stripped == 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if rest == nil {
|
||||||
|
rest = []any{}
|
||||||
|
}
|
||||||
|
holder["room_contents"] = rest
|
||||||
|
fields["state"], _ = json.Marshal(encodeScene(scene))
|
||||||
|
out, err := json.Marshal(fields)
|
||||||
|
p.logf("decor: kept %d community products out of a room state sent to IMVU", stripped)
|
||||||
|
return out, err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// incomingScene adds the room's decorations to its owner's broadcast.
|
||||||
|
func (p *Products) incomingScene(m *chatMessage) ([]byte, bool) {
|
||||||
|
scene, ok := decodeScene(strings.TrimPrefix(m.text, setRoomStateCommand))
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
encoded, changed := p.decorateScene(scene)
|
||||||
|
if !changed {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return m.withText(setRoomStateCommand + encoded), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// decorateScene adds the room's decorations to a scene IMVU sent: the scene encoded again, and whether it changed.
|
||||||
|
func (p *Products) decorateScene(scene map[string]any) (string, bool) {
|
||||||
|
instance := sceneInstance(scene)
|
||||||
|
if isHostedInstance(instance) {
|
||||||
|
holder, rows := sceneContents(scene)
|
||||||
|
out, changed := p.localScene(rows)
|
||||||
|
if !changed {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
holder["room_contents"] = out
|
||||||
|
return encodeScene(scene), true
|
||||||
|
}
|
||||||
|
if instance == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
holder, rows := sceneContents(scene)
|
||||||
|
p.decor.setBase(instance, rows, holder["room_info"])
|
||||||
|
items, fresh := p.decor.get(instance)
|
||||||
|
if !fresh {
|
||||||
|
items, _ = p.decor.last(instance) // stale or none: used now, asked for again for next time
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
p.decorRefresh(ctx, instance)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
extra := p.decorLocals(items)
|
||||||
|
if len(extra) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
holder["room_contents"] = append(append([]any{}, rows...), extra...)
|
||||||
|
return encodeScene(scene), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecorChanged is told by the hub that a room's decorations changed: they're asked for again, and if the client is
|
||||||
|
// in that room, it gets the room again with them (as its owner's broadcast).
|
||||||
|
func (p *Products) DecorChanged(instance string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
items := p.decorRefresh(ctx, instance) // the old ones stay in use until then
|
||||||
|
if roomOwner(instance) == p.User() {
|
||||||
|
return // the owner's client has the room as it is
|
||||||
|
}
|
||||||
|
base, info := p.decor.getBase(instance)
|
||||||
|
if base == nil || info == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.roomMu.Lock()
|
||||||
|
p.room.init()
|
||||||
|
var chat string
|
||||||
|
for c, inst := range p.room.rooms {
|
||||||
|
if inst == instance && p.room.inChat(c) {
|
||||||
|
chat = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var conns []*proxy.IMQConn
|
||||||
|
for c, chats := range p.room.subs {
|
||||||
|
if chats[chat] {
|
||||||
|
conns = append(conns, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.roomMu.Unlock()
|
||||||
|
if chat == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
owner := roomOwner(instance)
|
||||||
|
scene := map[string]any{"room_state": map[string]any{
|
||||||
|
"room_info": info,
|
||||||
|
"room_contents": append(append([]any{}, base...), p.decorLocals(items)...),
|
||||||
|
}}
|
||||||
|
chatID, _ := strconv.ParseInt(chat, 10, 64)
|
||||||
|
msg, _ := json.Marshal(map[string]any{"userId": owner, "chatId": chatID,
|
||||||
|
"message": setRoomStateCommand + encodeScene(scene), "to": 0})
|
||||||
|
for _, c := range conns {
|
||||||
|
c.Deliver(imq.Delivered{UserID: "/user/" + strconv.FormatInt(owner, 10), Queue: "/chat/" + chat, Mount: "messages", Message: msg})
|
||||||
|
}
|
||||||
|
p.logf("decor: gave the client %s again with its decorations", instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
// globalScene gives a hosted room's community products as the hub's product IDs (in the row and its placement); a
|
||||||
|
// local pack's can't be shared and is left out.
|
||||||
|
func (p *Products) globalScene(rows []any) ([]any, bool) {
|
||||||
|
out, changed := make([]any, 0, len(rows)), false
|
||||||
|
for _, row := range rows {
|
||||||
|
r, ok := row.([]any)
|
||||||
|
product, isNumber := int64(0), false
|
||||||
|
if ok && len(r) >= 3 {
|
||||||
|
product, isNumber = numberOf(r[1])
|
||||||
|
}
|
||||||
|
if !isNumber || !IsCommunity(product) {
|
||||||
|
out = append(out, row)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
id, ok := p.state.productID(product)
|
||||||
|
if !ok || !p.inHubDir(id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
placement, _ := r[2].(string)
|
||||||
|
out = append(out, []any{r[0], id, swapToken(placement, strconv.FormatInt(product, 10), id)})
|
||||||
|
}
|
||||||
|
return out, changed
|
||||||
|
}
|
||||||
|
|
||||||
|
// localScene turns hub product IDs in a hosted room's scene into local IDs (asking for products that aren't here).
|
||||||
|
func (p *Products) localScene(rows []any) ([]any, bool) {
|
||||||
|
out, changed := make([]any, 0, len(rows)), false
|
||||||
|
for _, row := range rows {
|
||||||
|
r, ok := row.([]any)
|
||||||
|
if !ok || len(r) < 3 {
|
||||||
|
out = append(out, row)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, isString := r[1].(string)
|
||||||
|
if !isString || !hubIDPattern.MatchString(id) {
|
||||||
|
out = append(out, row)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
local, ok := p.LocalID(id)
|
||||||
|
if !ok {
|
||||||
|
if p.Want != nil {
|
||||||
|
p.Want(id)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
placement, _ := r[2].(string)
|
||||||
|
out = append(out, []any{r[0], local, swapToken(placement, id, strconv.FormatInt(local, 10))})
|
||||||
|
}
|
||||||
|
return out, changed
|
||||||
|
}
|
||||||
|
|
||||||
|
var hubIDPattern = regexp.MustCompile(`^[a-z2-7]{10}$`)
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package products
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/imq"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/xmlrpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSceneEncoding(t *testing.T) {
|
||||||
|
scene := map[string]any{"room_state": map[string]any{"room_contents": []any{[]any{1, 2, "x"}}}}
|
||||||
|
back, ok := decodeScene(encodeScene(scene))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("didn't decode")
|
||||||
|
}
|
||||||
|
if _, rows := sceneContents(back); len(rows) != 1 {
|
||||||
|
t.Fatalf("rows %v", rows)
|
||||||
|
}
|
||||||
|
if enc := encodeScene(scene); !strings.HasPrefix(enc, "c") || !strings.HasSuffix(enc, "\n") {
|
||||||
|
t.Fatalf("encoded %q", enc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecorations(t *testing.T) {
|
||||||
|
h, _ := newHarness(t)
|
||||||
|
hubDir := t.TempDir()
|
||||||
|
writePack(t, hubDir, "sofasofa22", 1)
|
||||||
|
h.p.dirs = append(h.p.dirs, hubDir)
|
||||||
|
h.p.HubDir = hubDir
|
||||||
|
h.p.refresh(true)
|
||||||
|
sofa := h.p.byProductID("sofasofa22").local
|
||||||
|
h.do("POST", "https://secure.imvu.com/api/xmlrpc/client.php", call("test.avatarInfoForLogin2"), false, false)
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
stored := map[string][]DecorItem{}
|
||||||
|
saved := make(chan string, 4)
|
||||||
|
h.p.Decorations = func(ctx context.Context, instance string) ([]DecorItem, error) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
return stored[instance], nil
|
||||||
|
}
|
||||||
|
h.p.SaveDecorations = func(ctx context.Context, instance string, items []DecorItem) error {
|
||||||
|
mu.Lock()
|
||||||
|
stored[instance] = items
|
||||||
|
mu.Unlock()
|
||||||
|
saved <- instance
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The owner saves the room: IMVU gets it without the community sofa, the hub gets the sofa.
|
||||||
|
save := xmlrpc.Struct{{Name: "room_instance_id", Value: "7-1"}, {Name: "room_contents", Value: []xmlrpc.Value{
|
||||||
|
[]xmlrpc.Value{int64(10), int64(301), "2-1"}, []xmlrpc.Value{int64(11), sofa, fmt.Sprintf("3 0 11 %d 632", sofa)}}}}
|
||||||
|
h.do("POST", "http://client-dynamic.imvu.com/api/xmlrpc/client.php", call("test.setRoomState", save), true, false)
|
||||||
|
if asked := h.imvu.asked["test.setRoomState"]; len(asked) != 1 || strings.Contains(asked[0], fmt.Sprint(sofa)) || !strings.Contains(asked[0], "301") {
|
||||||
|
t.Fatalf("IMVU got %v", asked)
|
||||||
|
}
|
||||||
|
if <-saved != "7-1" || fmt.Sprint(stored["7-1"]) != "[{11 sofasofa22 3 0 11 sofasofa22 632}]" {
|
||||||
|
t.Fatalf("stored %v", stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading the room (anyone): the sofa is added back.
|
||||||
|
h.p.decor = decorations{} // as if another computer
|
||||||
|
_, body := h.do("POST", "http://client-dynamic.imvu.com/api/xmlrpc/client.php", call("test.getRoomState", xmlrpc.Struct{{Name: "room_instance_id", Value: "7-1"}}), true, false)
|
||||||
|
if !strings.Contains(string(body), fmt.Sprintf("3 0 11 %d 632", sofa)) || !strings.Contains(string(body), "301") {
|
||||||
|
t.Fatalf("loaded %s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The owner's broadcast: the sofa is taken out for IMVU...
|
||||||
|
scene := map[string]any{"room_state": map[string]any{
|
||||||
|
"room_info": map[string]any{"customers_id": "7", "room_instance_id": "7-1", "room_pid": "300"},
|
||||||
|
"room_contents": []any{[]any{10, 301, "2-1"}, []any{11, sofa, "2-2"}}}}
|
||||||
|
msg, _ := json.Marshal(map[string]any{"userId": 7, "chatId": 555, "message": setRoomStateCommand + encodeScene(scene), "to": 0})
|
||||||
|
out, drop := h.p.Outgoing("/chat/555", "messages", msg)
|
||||||
|
if drop {
|
||||||
|
t.Fatal("dropped")
|
||||||
|
}
|
||||||
|
m, _ := parseChatMessage(out)
|
||||||
|
sent, _ := decodeScene(strings.TrimPrefix(m.text, setRoomStateCommand))
|
||||||
|
if _, rows := sceneContents(sent); len(rows) != 1 {
|
||||||
|
t.Fatalf("sent to IMVU: %v", rows)
|
||||||
|
}
|
||||||
|
// ...and added back for whoever gets it with T5DE.
|
||||||
|
in := h.p.Incoming(nil, imq.Delivered{UserID: "/user/7", Queue: "/chat/555", Mount: "messages", Message: out})
|
||||||
|
m, _ = parseChatMessage(in)
|
||||||
|
got, _ := decodeScene(strings.TrimPrefix(m.text, setRoomStateCommand))
|
||||||
|
if _, rows := sceneContents(got); len(rows) != 2 {
|
||||||
|
t.Fatalf("delivered: %v", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A broadcast arriving while the proxy asks the hub for changed decorations still gets the ones known so far.
|
||||||
|
func TestDecorationsWhileRefreshing(t *testing.T) {
|
||||||
|
h, _ := newHarness(t)
|
||||||
|
hubDir := t.TempDir()
|
||||||
|
writePack(t, hubDir, "sofasofa22", 1)
|
||||||
|
h.p.dirs = append(h.p.dirs, hubDir)
|
||||||
|
h.p.HubDir = hubDir
|
||||||
|
h.p.refresh(true)
|
||||||
|
sofa := h.p.byProductID("sofasofa22").local
|
||||||
|
release := make(chan struct{})
|
||||||
|
asked := make(chan struct{}, 1)
|
||||||
|
h.p.Decorations = func(ctx context.Context, instance string) ([]DecorItem, error) {
|
||||||
|
asked <- struct{}{}
|
||||||
|
<-release
|
||||||
|
return []DecorItem{{11, "sofasofa22", "x"}, {12, "sofasofa22", "y"}}, nil
|
||||||
|
}
|
||||||
|
h.p.decor.set("7-1", []DecorItem{{11, "sofasofa22", "x"}})
|
||||||
|
go h.p.DecorChanged("7-1")
|
||||||
|
<-asked // the hub is being asked
|
||||||
|
scene := map[string]any{"room_state": map[string]any{
|
||||||
|
"room_info": map[string]any{"room_instance_id": "7-1"}, "room_contents": []any{[]any{10, 301, "2-1"}}}}
|
||||||
|
msg, _ := json.Marshal(map[string]any{"userId": 7, "chatId": 555, "message": setRoomStateCommand + encodeScene(scene)})
|
||||||
|
in := h.p.Incoming(nil, imq.Delivered{UserID: "7", Queue: "/chat/555", Mount: "messages", Message: msg})
|
||||||
|
close(release)
|
||||||
|
m, _ := parseChatMessage(in)
|
||||||
|
got, _ := decodeScene(strings.TrimPrefix(m.text, setRoomStateCommand))
|
||||||
|
if _, rows := sceneContents(got); len(rows) != 2 || !strings.Contains(fmt.Sprint(rows), fmt.Sprint(sofa)) {
|
||||||
|
t.Fatalf("delivered %v", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMVU's server sends a saved room on the chat's control mount; decorations are added to it too.
|
||||||
|
func TestDecorationsInServerRoomState(t *testing.T) {
|
||||||
|
h, _ := newHarness(t)
|
||||||
|
hubDir := t.TempDir()
|
||||||
|
writePack(t, hubDir, "sofasofa22", 1)
|
||||||
|
h.p.dirs = append(h.p.dirs, hubDir)
|
||||||
|
h.p.HubDir = hubDir
|
||||||
|
h.p.refresh(true)
|
||||||
|
h.p.decor.set("7-1", []DecorItem{{11, "sofasofa22", "x"}})
|
||||||
|
scene := map[string]any{"room_state": map[string]any{
|
||||||
|
"room_info": map[string]any{"room_instance_id": "7-1"}, "room_contents": []any{[]any{10, 301, "2-1"}}}}
|
||||||
|
msg, _ := json.Marshal(map[string]any{"command": "SetRoomState", "state": encodeScene(scene)})
|
||||||
|
in := h.p.Incoming(nil, imq.Delivered{UserID: "admin", Queue: "/chat/555", Mount: "control", Message: msg})
|
||||||
|
var fields map[string]string
|
||||||
|
json.Unmarshal(in, &fields)
|
||||||
|
got, _ := decodeScene(fields["state"])
|
||||||
|
if _, rows := sceneContents(got); len(rows) != 2 || fields["command"] != "SetRoomState" {
|
||||||
|
t.Fatalf("delivered %s", in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In a hosted room, community products travel as the hub's IDs and come back as local IDs.
|
||||||
|
func TestHostedRoomScene(t *testing.T) {
|
||||||
|
h, _ := newHarness(t)
|
||||||
|
hubDir := t.TempDir()
|
||||||
|
writePack(t, hubDir, "sofasofa22", 1)
|
||||||
|
h.p.dirs = append(h.p.dirs, hubDir)
|
||||||
|
h.p.HubDir = hubDir
|
||||||
|
h.p.refresh(true)
|
||||||
|
sofa := h.p.byProductID("sofasofa22").local
|
||||||
|
scene := map[string]any{"room_state": map[string]any{
|
||||||
|
"room_info": map[string]any{"room_instance_id": "7-2100000003"},
|
||||||
|
"room_contents": []any{[]any{10, 301, "2-1"}, []any{11, sofa, fmt.Sprintf("3 0 11 %d 6", sofa)}}}}
|
||||||
|
msg, _ := json.Marshal(map[string]any{"userId": 7, "message": setRoomStateCommand + encodeScene(scene)})
|
||||||
|
out, _ := h.p.outgoing("/chat/2100000003", "messages", msg)
|
||||||
|
m, _ := parseChatMessage(out)
|
||||||
|
sent, _ := decodeScene(strings.TrimPrefix(m.text, setRoomStateCommand))
|
||||||
|
if _, rows := sceneContents(sent); fmt.Sprint(rows) != "[[10 301 2-1] [11 sofasofa22 3 0 11 sofasofa22 6]]" {
|
||||||
|
t.Fatalf("sent %v", rows)
|
||||||
|
}
|
||||||
|
in := h.p.Incoming(nil, imq.Delivered{UserID: "7", Queue: "/chat/2100000003", Mount: "messages", Message: out})
|
||||||
|
m, _ = parseChatMessage(in)
|
||||||
|
got, _ := decodeScene(strings.TrimPrefix(m.text, setRoomStateCommand))
|
||||||
|
if _, rows := sceneContents(got); fmt.Sprint(rows) != fmt.Sprintf("[[10 301 2-1] [11 %d 3 0 11 %d 6]]", sofa, sofa) {
|
||||||
|
t.Fatalf("delivered %v", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package products
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Guard is the last check before a request goes to IMVU: one that still names a community product (a local ID the
|
||||||
|
// proxy gave out, in its query or body, as an integer or a float) is refused, not sent. The interceptors take
|
||||||
|
// community products out of every request known to carry them; Guard stops the ones not known yet, and logs them
|
||||||
|
// so they can be handled.
|
||||||
|
type Guard struct{ Products *Products }
|
||||||
|
|
||||||
|
var localIDPattern = regexp.MustCompile(`(?:^|[^0-9.])(2[0-9]{9})(?:\.0+)?(?:[^0-9.]|$)`)
|
||||||
|
|
||||||
|
func (g Guard) Intercept(x *proxy.Exchange) error {
|
||||||
|
if x.Response != nil || !strings.HasSuffix(strings.ToLower(x.URL.Hostname()), ".imvu.com") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
query, _ := url.QueryUnescape(x.URL.RawQuery)
|
||||||
|
for _, text := range []string{query, x.URL.Path, string(x.Body)} {
|
||||||
|
for _, m := range localIDPattern.FindAllStringSubmatch(text, -1) {
|
||||||
|
id, ok := parseID(m[1])
|
||||||
|
if ok && IsCommunity(id) && g.Products.given(id) {
|
||||||
|
g.Products.logf("products: refused a request to %s%s: it names community product %d", x.URL.Host, x.URL.Path, id)
|
||||||
|
x.Response = localResponse(403, "application/json", []byte(`{"error":"t5de: community products stay off IMVU"}`))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// given reports whether a local ID was given to a product.
|
||||||
|
func (p *Products) given(local int64) bool {
|
||||||
|
_, ok := p.state.productID(local)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package products
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGuard(t *testing.T) {
|
||||||
|
h, _ := newHarness(t)
|
||||||
|
local := h.p.byProductID("blue-coat").local
|
||||||
|
g := Guard{Products: h.p}
|
||||||
|
check := func(rawURL, body string, refused bool) {
|
||||||
|
t.Helper()
|
||||||
|
u, _ := url.Parse(rawURL)
|
||||||
|
req, _ := http.NewRequest("POST", rawURL, nil)
|
||||||
|
x := &proxy.Exchange{URL: u, Request: req, Body: []byte(body)}
|
||||||
|
g.Intercept(x)
|
||||||
|
if (x.Response != nil) != refused {
|
||||||
|
t.Errorf("%s %s: refused %v", rawURL, body, x.Response != nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check("https://client-dynamic.imvu.com/api/new.php", fmt.Sprintf(`{"pids": [80, %d.0]}`, local), true)
|
||||||
|
check(fmt.Sprintf("https://api.imvu.com/product/product-%d", local), "", true)
|
||||||
|
check(fmt.Sprintf("https://client-dynamic.imvu.com/api/x.php?ids=1%%2C%d", local), "", true)
|
||||||
|
check("https://client-dynamic.imvu.com/api/new.php", `{"pids": [80, 2000000777]}`, false) // not given out
|
||||||
|
check("https://client-dynamic.imvu.com/api/new.php", fmt.Sprintf(`{"n": "1%d"}`, local), false)
|
||||||
|
check(fmt.Sprintf("https://hub.example/%d", local), "", false) // only IMVU's hosts
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package products
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseID reads a product ID as the client writes it: "2000000008", or as a float, "2000000008.0" (its JavaScript
|
||||||
|
// numbers go through Python as floats). Every place that takes community products out of what IMVU gets uses it, so a
|
||||||
|
// float can't slip through as "not a product ID".
|
||||||
|
func parseID(s string) (int64, bool) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if id, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
f, err := strconv.ParseFloat(s, 64)
|
||||||
|
if err != nil || f != math.Trunc(f) || math.Abs(f) > 1<<53 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return int64(f), true
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package products
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseID(t *testing.T) {
|
||||||
|
for s, want := range map[string]int64{"2000000008": 2000000008, "2000000008.0": 2000000008, " 80 ": 80, "2e9": 2000000000} {
|
||||||
|
if got, ok := parseID(s); !ok || got != want {
|
||||||
|
t.Errorf("parseID(%q) = %d, %v", s, got, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range []string{"", "1.5", "abc", "1e300"} {
|
||||||
|
if _, ok := parseID(s); ok {
|
||||||
|
t.Errorf("parseID(%q) ok", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,767 @@
|
|||||||
|
package products
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/xmlrpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func localResponse(status int, contentType string, body []byte) *http.Response {
|
||||||
|
return proxy.LocalResponse(status, contentType, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intercept implements proxy.Interceptor.
|
||||||
|
func (p *Products) Intercept(x *proxy.Exchange) error {
|
||||||
|
host, path := strings.ToLower(x.URL.Hostname()), x.URL.Path
|
||||||
|
if strings.HasSuffix(host, ".imvu.com") && strings.HasPrefix(path, "/productdata/") {
|
||||||
|
if resp, ok := p.serveFile(path); ok {
|
||||||
|
x.Response = resp
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(host, ".imvu.com") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if host == "api.imvu.com" && participantPath.MatchString(path) {
|
||||||
|
return p.interceptParticipant(x)
|
||||||
|
}
|
||||||
|
if host == "api.imvu.com" {
|
||||||
|
if m := productPath.FindStringSubmatch(path); m != nil {
|
||||||
|
if id, ok := parseID(m[1]); ok && IsCommunity(id) {
|
||||||
|
x.Response = productAPIAnswer(path, m[2])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch path {
|
||||||
|
case "/api/xmlrpc/chat.php":
|
||||||
|
return p.interceptChat(x)
|
||||||
|
case "/api/xmlrpc/client.php":
|
||||||
|
return p.interceptXMLRPC(x)
|
||||||
|
case "/api/shop/product.php":
|
||||||
|
// Putting a product on asks the shop whether it's a bundle; community products aren't.
|
||||||
|
if id, ok := parseID(x.URL.Query().Get("pid")); ok && IsCommunity(id) {
|
||||||
|
x.Response = localResponse(200, "application/json", []byte(`{"products":[]}`))
|
||||||
|
} else if len(x.Body) > 0 {
|
||||||
|
return p.interceptProductInfo(x)
|
||||||
|
}
|
||||||
|
case "/api/outfit_products.php":
|
||||||
|
return p.interceptOutfitProducts(x)
|
||||||
|
case "/api/find_bundle.php":
|
||||||
|
return p.interceptFindBundle(x)
|
||||||
|
case "/api/recently_added_inventory.php":
|
||||||
|
return p.interceptRecent(x)
|
||||||
|
case "/api/shop/search_st.php":
|
||||||
|
return p.interceptShopSearch(x)
|
||||||
|
case "/api/service/shop/svc_catalog_purchase.php":
|
||||||
|
return p.interceptPurchase(x)
|
||||||
|
case "/api/base_look.php":
|
||||||
|
return p.interceptBaseLook(x)
|
||||||
|
case "/api/service/inventory/inventory_delete.php":
|
||||||
|
return p.interceptHide(x)
|
||||||
|
case "/api/outfits.php":
|
||||||
|
return p.interceptOutfits(x)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// productPath is IMVU's REST API for a product (api.imvu.com/product/product-<id>[/<relation>]).
|
||||||
|
var productPath = regexp.MustCompile(`^/product/product-([0-9.]+)(/[a-z_]+)?$`)
|
||||||
|
|
||||||
|
// productAPIAnswer answers the REST API about a community product: no related products (uml_products, which the
|
||||||
|
// shop's recommender asks for when a product is tried on), and nothing else.
|
||||||
|
func productAPIAnswer(path, relation string) *http.Response {
|
||||||
|
id := "https://api.imvu.com" + path
|
||||||
|
if relation == "/uml_products" {
|
||||||
|
b, _ := json.Marshal(map[string]any{"status": "success", "id": id, "denormalized": map[string]any{
|
||||||
|
id: map[string]any{"data": map[string]any{"total_count": 0, "items": []any{}}, "relations": map[string]any{}}}})
|
||||||
|
return localResponse(200, "application/json", b)
|
||||||
|
}
|
||||||
|
return localResponse(404, "application/json", []byte(`{"status":"failure","error":"NOT_FOUND"}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Products) interceptXMLRPC(x *proxy.Exchange) error {
|
||||||
|
call, err := xmlrpc.ParseCall(x.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil // not ours to judge; IMVU answers
|
||||||
|
}
|
||||||
|
switch call.Method {
|
||||||
|
case "test.avatarInfoForLogin2":
|
||||||
|
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
|
||||||
|
p.keys.learn(body)
|
||||||
|
if m := userIDPattern.FindSubmatch(body); m != nil {
|
||||||
|
p.mu.Lock()
|
||||||
|
p.user = string(m[1])
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
if m := userIDPattern.FindSubmatch(body); m != nil && p.Observer != nil {
|
||||||
|
if id, err := strconv.ParseInt(string(m[1]), 10, 64); err == nil {
|
||||||
|
p.Observer.Session(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p.addToLook(body)
|
||||||
|
}
|
||||||
|
case "test.inventoryProductBatch":
|
||||||
|
return p.inventoryProductBatch(x, call)
|
||||||
|
case "test.authorizeProducts2":
|
||||||
|
return p.authorizeProducts2(x, call)
|
||||||
|
case "test.showInventory":
|
||||||
|
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
|
||||||
|
return p.addToInventory(body)
|
||||||
|
}
|
||||||
|
case "test.setRoomState":
|
||||||
|
if len(call.Params) == 1 {
|
||||||
|
return p.interceptSetRoomState(x, call)
|
||||||
|
}
|
||||||
|
case "test.getRoomState":
|
||||||
|
if len(call.Params) == 1 {
|
||||||
|
return p.interceptGetRoomState(x, call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// replaceBody sends call instead of the client's, re-signed.
|
||||||
|
func (p *Products) replaceBody(x *proxy.Exchange, call *xmlrpc.Call) error {
|
||||||
|
body := call.Marshal()
|
||||||
|
if !p.keys.resign(x.Request.Header, x.Body, x.URL.RawQuery, body, x.URL.RawQuery) {
|
||||||
|
return errNoKey
|
||||||
|
}
|
||||||
|
x.Body = body
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addToLook adds the user's community products to the outfit ("look") in a login response.
|
||||||
|
func (p *Products) addToLook(body []byte) ([]byte, error) {
|
||||||
|
m := userIDPattern.FindSubmatch(body)
|
||||||
|
if m == nil {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
user := string(m[1])
|
||||||
|
r, err := xmlrpc.ParseResponse(body)
|
||||||
|
if err != nil || r.Fault != nil {
|
||||||
|
return body, err
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
var walk func(v xmlrpc.Value) xmlrpc.Value
|
||||||
|
walk = func(v xmlrpc.Value) xmlrpc.Value {
|
||||||
|
switch v := v.(type) {
|
||||||
|
case xmlrpc.Struct:
|
||||||
|
for i, mem := range v {
|
||||||
|
if mem.Name == "look" {
|
||||||
|
if look, ok := productList(mem.Value); ok {
|
||||||
|
if worn := p.wornLocal(user, look); len(worn) > 0 {
|
||||||
|
p.reportOutfit(user, worn)
|
||||||
|
v[i].Value = appendIDs(mem.Value.([]xmlrpc.Value), worn)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v[i].Value = walk(mem.Value)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
case []xmlrpc.Value:
|
||||||
|
for i := range v {
|
||||||
|
v[i] = walk(v[i])
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
for i := range r.Params {
|
||||||
|
r.Params[i] = walk(r.Params[i])
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
return r.Marshal(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendIDs(list []xmlrpc.Value, ids []int64) []xmlrpc.Value {
|
||||||
|
have := map[int64]bool{}
|
||||||
|
for _, v := range list {
|
||||||
|
if n, ok := v.(int64); ok {
|
||||||
|
have[n] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
if !have[id] {
|
||||||
|
list = append(list, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// productList reads a list of product IDs.
|
||||||
|
func productList(v xmlrpc.Value) ([]int64, bool) {
|
||||||
|
list, ok := v.([]xmlrpc.Value)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
ids := make([]int64, 0, len(list))
|
||||||
|
for _, v := range list {
|
||||||
|
switch n := v.(type) {
|
||||||
|
case int64:
|
||||||
|
ids = append(ids, n)
|
||||||
|
case float64:
|
||||||
|
id, ok := parseID(strconv.FormatFloat(n, 'f', -1, 64))
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
default:
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// inventoryProductBatch answers product info for community products: the parent's info from IMVU with the
|
||||||
|
// product's own name, creator and ID, or (without a parent) info of its own.
|
||||||
|
func (p *Products) inventoryProductBatch(x *proxy.Exchange, call *xmlrpc.Call) error {
|
||||||
|
if len(call.Params) != 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ids, ok := productList(call.Params[0])
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var community []int64
|
||||||
|
var forward []xmlrpc.Value
|
||||||
|
asked := map[int64]bool{}
|
||||||
|
for _, id := range ids {
|
||||||
|
if !IsCommunity(id) {
|
||||||
|
if !asked[id] {
|
||||||
|
forward, asked[id] = append(forward, id), true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
community = append(community, id)
|
||||||
|
if s := p.lookup(id); s != nil && s.pack.Index.Parent > 0 && !asked[int64(s.pack.Index.Parent)] {
|
||||||
|
forward, asked[int64(s.pack.Index.Parent)] = append(forward, int64(s.pack.Index.Parent)), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(community) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// The answer, in the order asked, from IMVU's entries (by product ID) and our own.
|
||||||
|
answer := func(imvu map[int64]xmlrpc.Struct) []byte {
|
||||||
|
var out []xmlrpc.Value
|
||||||
|
for _, id := range ids {
|
||||||
|
if !IsCommunity(id) {
|
||||||
|
if e, ok := imvu[id]; ok {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s := p.lookup(id); s != nil {
|
||||||
|
out = append(out, s.productInfo(imvu[int64(s.pack.Index.Parent)]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (&xmlrpc.Response{Params: []xmlrpc.Value{out}}).Marshal()
|
||||||
|
}
|
||||||
|
if len(forward) == 0 {
|
||||||
|
x.Response = localResponse(200, "text/xml", answer(nil))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := p.replaceBody(x, &xmlrpc.Call{Method: call.Method, Params: []xmlrpc.Value{forward}}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
|
||||||
|
r, err := xmlrpc.ParseResponse(body)
|
||||||
|
if err != nil || r.Fault != nil || len(r.Params) != 1 {
|
||||||
|
return body, err
|
||||||
|
}
|
||||||
|
list, _ := r.Params[0].([]xmlrpc.Value)
|
||||||
|
imvu := map[int64]xmlrpc.Struct{}
|
||||||
|
for _, v := range list {
|
||||||
|
if e, ok := v.(xmlrpc.Struct); ok {
|
||||||
|
if id, ok := e.Get("products_id"); ok {
|
||||||
|
if n, ok := id.(int64); ok {
|
||||||
|
imvu[n] = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return answer(imvu), nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// productInfo is a community product's entry for inventoryProductBatch, based on its parent's if there is one.
|
||||||
|
func (s *served) productInfo(parent xmlrpc.Struct) xmlrpc.Struct {
|
||||||
|
m := s.pack.Manifest
|
||||||
|
mature := "N"
|
||||||
|
if m.Rating == "adult" {
|
||||||
|
mature = "Y"
|
||||||
|
}
|
||||||
|
own := map[string]xmlrpc.Value{
|
||||||
|
"products_id": s.local,
|
||||||
|
"manufacturers_id": int64(0),
|
||||||
|
"manufacturers_name": plain(m.Creator),
|
||||||
|
"products_name": plain(m.Name),
|
||||||
|
"products_mature": mature,
|
||||||
|
"products_bundle": "",
|
||||||
|
"is_published": "1",
|
||||||
|
"is_purchasable": false,
|
||||||
|
}
|
||||||
|
var out xmlrpc.Struct
|
||||||
|
done := map[string]bool{}
|
||||||
|
for _, mem := range parent {
|
||||||
|
if v, ok := own[mem.Name]; ok {
|
||||||
|
out = append(out, xmlrpc.Member{Name: mem.Name, Value: v})
|
||||||
|
done[mem.Name] = true
|
||||||
|
} else {
|
||||||
|
out = append(out, mem) // cPath, products_image, ... from the parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range []string{"products_id", "manufacturers_id", "manufacturers_name", "products_name", "cPath",
|
||||||
|
"products_mature", "products_bundle", "products_image", "is_published", "is_purchasable"} {
|
||||||
|
if done[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v, ok := own[name]
|
||||||
|
if !ok {
|
||||||
|
v = "" // cPath and products_image, without a parent
|
||||||
|
}
|
||||||
|
if _, inParent := parent.Get(name); !inParent {
|
||||||
|
out = append(out, xmlrpc.Member{Name: name, Value: v})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// authColumns are authorizeProducts2's answer columns, as IMVU sends them.
|
||||||
|
var authColumns = []string{"userId", "parentProductId", "productId", "cflChecksum", "cflUrl", "sub_pids",
|
||||||
|
"usableDirect", "autoAuth", "usableDerivation", "usableTrial", "trialTimeout", "usablePeerReview", "info"}
|
||||||
|
|
||||||
|
// authorizeProducts2 answers permission and file addresses for community products, and asks IMVU for their
|
||||||
|
// parents' (the client needs the whole derivation chain).
|
||||||
|
func (p *Products) authorizeProducts2(x *proxy.Exchange, call *xmlrpc.Call) error {
|
||||||
|
if len(call.Params) != 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
args, ok := call.Params[0].(xmlrpc.Struct)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
authsValue, _ := args.Get("auths")
|
||||||
|
table, ok := authsValue.([]xmlrpc.Value)
|
||||||
|
if !ok || len(table) < 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
header, ok := table[0].([]xmlrpc.Value)
|
||||||
|
if !ok || len(header) != 2 || header[0] != "userId" || header[1] != "productId" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
type auth struct {
|
||||||
|
user, product int64
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
all := append(rows, ours(columns)...)
|
||||||
|
r.Params[0] = all
|
||||||
|
return r.Marshal(), nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// interceptBaseLook keeps community products out of the outfit IMVU stores, and adds them back when the client
|
||||||
|
// loads it.
|
||||||
|
func (p *Products) interceptBaseLook(x *proxy.Exchange) error {
|
||||||
|
user := x.Request.Header.Get("X-imvu-userid")
|
||||||
|
if user == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(x.Body) == 0 { // loading the outfit
|
||||||
|
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
|
||||||
|
var look map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(body, &look); err != nil {
|
||||||
|
return body, err
|
||||||
|
}
|
||||||
|
var ids []int64
|
||||||
|
if err := json.Unmarshal(look["result"], &ids); err != nil {
|
||||||
|
return body, err
|
||||||
|
}
|
||||||
|
worn := p.wornLocal(user, ids)
|
||||||
|
p.reportOutfit(user, worn)
|
||||||
|
if len(worn) == 0 {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
have := map[int64]bool{}
|
||||||
|
for _, id := range ids {
|
||||||
|
have[id] = true
|
||||||
|
}
|
||||||
|
for _, id := range worn {
|
||||||
|
if !have[id] {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
look["result"], _ = json.Marshal(ids)
|
||||||
|
return json.Marshal(look)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Saving it: {"pids": [...]}.
|
||||||
|
var save map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(x.Body, &save); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var ids []int64
|
||||||
|
if err := json.Unmarshal(save["pids"], &ids); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var kept, community []int64
|
||||||
|
for _, id := range ids {
|
||||||
|
if IsCommunity(id) {
|
||||||
|
community = append(community, id)
|
||||||
|
} else {
|
||||||
|
kept = append(kept, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.recordWorn(user, kept, community)
|
||||||
|
if len(community) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p.logf("products: kept %d community products out of the outfit saved to IMVU", len(community))
|
||||||
|
save["pids"], _ = json.Marshal(kept)
|
||||||
|
body, _ := json.Marshal(save)
|
||||||
|
if !p.keys.resign(x.Request.Header, x.Body, x.URL.RawQuery, body, x.URL.RawQuery) {
|
||||||
|
return errNoKey
|
||||||
|
}
|
||||||
|
x.Body = body
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outgoing implements proxy.IMQFilter: community products are taken out of outfit chat commands (IMVU and other
|
||||||
|
// people see the defaults), and whole outfits are remembered.
|
||||||
|
func (p *Products) Outgoing(queue, mount string, message []byte) ([]byte, bool) {
|
||||||
|
out, drop := p.outgoing(queue, mount, message)
|
||||||
|
// A hosted room's message goes to the hub (without community products, as to IMVU), never to IMVU.
|
||||||
|
if p.Hosted != nil && p.Hosted.Send(queue, mount, out, message, drop) {
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
return out, drop
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Products) outgoing(queue, mount string, message []byte) ([]byte, bool) {
|
||||||
|
if strings.HasPrefix(queue, "/chat/") && mount == "control" {
|
||||||
|
if out, changed := p.outgoingControlScene(message); changed {
|
||||||
|
return out, false
|
||||||
|
}
|
||||||
|
return message, false
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(queue, "/chat/") || mount != "messages" {
|
||||||
|
return message, false
|
||||||
|
}
|
||||||
|
m, ok := parseChatMessage(message)
|
||||||
|
if !ok {
|
||||||
|
return message, false
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(m.text, setRoomStateCommand) {
|
||||||
|
if out, changed := p.outgoingScene(m); changed {
|
||||||
|
return out, false
|
||||||
|
}
|
||||||
|
return message, false
|
||||||
|
}
|
||||||
|
r := stripCommand(m.text, IsCommunity)
|
||||||
|
if r.outfit && m.userID != 0 {
|
||||||
|
p.recordWorn(strconv.FormatInt(m.userID, 10), r.kept, r.products)
|
||||||
|
}
|
||||||
|
if !r.changed {
|
||||||
|
return message, false
|
||||||
|
}
|
||||||
|
command, _, _ := strings.Cut(r.text, " ")
|
||||||
|
if r.empty {
|
||||||
|
p.logf("products: answered a %s of only community products without sending it", command)
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
p.logf("products: kept %d community products out of a %s sent to IMVU", len(r.products), command)
|
||||||
|
return m.withText(r.text), false
|
||||||
|
}
|
||||||
|
|
||||||
|
// plain keeps a name to characters the client's pages can show however they insert it: some insert creator and
|
||||||
|
// product names as HTML, in pages that can call into the client.
|
||||||
|
func plain(s string) string {
|
||||||
|
return strings.Map(func(r rune) rune {
|
||||||
|
switch r {
|
||||||
|
case '<', '>', '&', '"', '\'', '`':
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// inventoryLocals is the community products in the inventory: local packs, and hub products in the library.
|
||||||
|
func (p *Products) inventoryLocals() []int64 {
|
||||||
|
p.refresh(false)
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
var ids []int64
|
||||||
|
for id, s := range p.byID {
|
||||||
|
if p.HubDir != "" && filepath.Dir(s.dir) == filepath.Clean(p.HubDir) && (p.Owned == nil || !p.Owned(id)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ids = append(ids, s.local)
|
||||||
|
}
|
||||||
|
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// addToInventory adds the community products to test.showInventory's answer (a list of product IDs).
|
||||||
|
func (p *Products) addToInventory(body []byte) ([]byte, error) {
|
||||||
|
r, err := xmlrpc.ParseResponse(body)
|
||||||
|
if err != nil || r.Fault != nil || len(r.Params) != 1 {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
list, ok := r.Params[0].([]xmlrpc.Value)
|
||||||
|
if !ok {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
have := map[int64]bool{}
|
||||||
|
for _, v := range list {
|
||||||
|
if n, ok := v.(int64); ok {
|
||||||
|
have[n] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
added := 0
|
||||||
|
for _, id := range p.inventoryLocals() {
|
||||||
|
if !have[id] {
|
||||||
|
list = append(list, id)
|
||||||
|
added++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if added == 0 {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
r.Params[0] = list
|
||||||
|
p.logf("products: %d community products in the inventory", added)
|
||||||
|
return r.Marshal(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// interceptHide answers hiding a community product in the inventory itself (IMVU doesn't know it).
|
||||||
|
func (p *Products) interceptHide(x *proxy.Exchange) error {
|
||||||
|
form, err := url.ParseQuery(string(x.Body))
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pid, ok := parseID(form.Get("pid"))
|
||||||
|
if !ok || !IsCommunity(pid) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if id, ok := p.state.productID(pid); ok && p.OnHide != nil {
|
||||||
|
p.OnHide(id)
|
||||||
|
}
|
||||||
|
x.Response = localResponse(200, "application/json", []byte(`{"success": true}`))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// interceptOutfits keeps community products out of saved outfits IMVU stores (POST /api/outfits.php, JSON with
|
||||||
|
// "products": "1,2,3"), remembering them under the outfit's ID, and adds them back when the client loads saved
|
||||||
|
// outfits (GET /api/outfits.php?batch=... or ?outfit_id=...: {"result": [{"user_outfits_id", "pids", ...}]}).
|
||||||
|
func (p *Products) interceptOutfits(x *proxy.Exchange) error {
|
||||||
|
if len(x.Body) > 0 {
|
||||||
|
var save map[string]json.RawMessage
|
||||||
|
if json.Unmarshal(x.Body, &save) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
removed := p.outfitRemovals(save) // the outfit card's remove_pids
|
||||||
|
resave := func() error {
|
||||||
|
body, _ := json.Marshal(save)
|
||||||
|
if !p.keys.resign(x.Request.Header, x.Body, x.URL.RawQuery, body, x.URL.RawQuery) {
|
||||||
|
return errNoKey
|
||||||
|
}
|
||||||
|
x.Body = body
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var products string
|
||||||
|
if json.Unmarshal(save["products"], &products) != nil {
|
||||||
|
if removed {
|
||||||
|
return resave()
|
||||||
|
}
|
||||||
|
return nil // not a save (e.g. set_default_outfit_id)
|
||||||
|
}
|
||||||
|
var kept []string
|
||||||
|
var community []string
|
||||||
|
stripped := 0
|
||||||
|
for _, f := range strings.Split(products, ",") {
|
||||||
|
id, ok := parseID(f)
|
||||||
|
if ok && IsCommunity(id) {
|
||||||
|
stripped++ // always kept from IMVU, even if its product isn't known anymore
|
||||||
|
if pid, ok := p.state.productID(id); ok {
|
||||||
|
community = append(community, pid)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, f)
|
||||||
|
}
|
||||||
|
if stripped == 0 {
|
||||||
|
if removed {
|
||||||
|
return resave()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
save["products"], _ = json.Marshal(strings.Join(kept, ","))
|
||||||
|
body, _ := json.Marshal(save)
|
||||||
|
if !p.keys.resign(x.Request.Header, x.Body, x.URL.RawQuery, body, x.URL.RawQuery) {
|
||||||
|
return errNoKey
|
||||||
|
}
|
||||||
|
x.Body = body
|
||||||
|
p.logf("products: kept %d community products out of an outfit saved to IMVU", stripped)
|
||||||
|
x.ModifyResponse = func(resp *http.Response, b []byte) ([]byte, error) {
|
||||||
|
var r struct {
|
||||||
|
Result struct {
|
||||||
|
OutfitID json.Number `json:"outfit_id"`
|
||||||
|
} `json:"result"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(b, &r) == nil && r.Result.OutfitID != "" {
|
||||||
|
if err := p.state.setOutfit(r.Result.OutfitID.String(), community); err != nil {
|
||||||
|
p.logf("products: remembering an outfit: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
x.ModifyResponse = func(resp *http.Response, b []byte) ([]byte, error) {
|
||||||
|
var r map[string]json.RawMessage
|
||||||
|
if json.Unmarshal(b, &r) != nil {
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
var outfits []map[string]json.RawMessage
|
||||||
|
if json.Unmarshal(r["result"], &outfits) != nil {
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
for _, o := range outfits {
|
||||||
|
var id json.Number
|
||||||
|
if json.Unmarshal(o["user_outfits_id"], &id) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
products := p.state.outfit(id.String())
|
||||||
|
if len(products) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var pids []json.RawMessage
|
||||||
|
json.Unmarshal(o["pids"], &pids)
|
||||||
|
adult := false
|
||||||
|
for _, pid := range products {
|
||||||
|
if s := p.byProductID(pid); s != nil {
|
||||||
|
pids = append(pids, json.RawMessage(strconv.FormatInt(s.local, 10)))
|
||||||
|
adult = adult || s.pack.Manifest.Rating == "adult"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o["pids"], _ = json.Marshal(pids)
|
||||||
|
if adult {
|
||||||
|
var restricted string
|
||||||
|
json.Unmarshal(o["restricted"], &restricted)
|
||||||
|
if restricted == "" || restricted == "0" {
|
||||||
|
o["restricted"], _ = json.Marshal("1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
r["result"], _ = json.Marshal(outfits)
|
||||||
|
return json.Marshal(r)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package products
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The client's outfit card (dialogs/outfit_card) lists an outfit's products from api/outfit_products.php, asks
|
||||||
|
// api/find_bundle.php whether the outfit's products are a bundle on sale, and saves changes (removing products with
|
||||||
|
// remove_pids) to api/outfits.php. IMVU only has the outfit without its community products (see interceptOutfits),
|
||||||
|
// so the proxy adds them to the list, and keeps them out of the rest.
|
||||||
|
|
||||||
|
// interceptOutfitProducts adds an outfit's community products to its product list.
|
||||||
|
func (p *Products) interceptOutfitProducts(x *proxy.Exchange) error {
|
||||||
|
outfit := x.URL.Query().Get("outfit_id")
|
||||||
|
products := p.state.outfit(outfit)
|
||||||
|
if outfit == "" || len(products) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
|
||||||
|
var r map[string]json.RawMessage
|
||||||
|
if json.Unmarshal(body, &r) != nil {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
var list map[string]json.RawMessage
|
||||||
|
if json.Unmarshal(r["result"], &list) != nil {
|
||||||
|
if string(r["result"]) != "[]" { // PHP's empty object
|
||||||
|
p.logf("products: outfit_products.php: unexpected result")
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
list = map[string]json.RawMessage{}
|
||||||
|
}
|
||||||
|
for _, id := range products {
|
||||||
|
if row, ok := p.outfitCardRow(id); ok {
|
||||||
|
list[strconv.FormatInt(row.local, 10)], _ = json.Marshal(row.entry())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r["result"], _ = json.Marshal(list)
|
||||||
|
return json.Marshal(r)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type cardRow struct {
|
||||||
|
hub.Product
|
||||||
|
local int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// entry is the outfit card's product entry. Community products can't be in a bundle for sale on IMVU.
|
||||||
|
func (r cardRow) entry() map[string]any {
|
||||||
|
category, categoryID := "", int64(0)
|
||||||
|
if n := len(r.Categories); n > 0 {
|
||||||
|
category, categoryID = categoryText(r.Categories[n-1].Name), r.Categories[n-1].ID
|
||||||
|
}
|
||||||
|
ap := 0
|
||||||
|
if r.Rating == "adult" {
|
||||||
|
ap = 1
|
||||||
|
}
|
||||||
|
return map[string]any{"name": plain(r.Name), "image": r.Image, "category_id": categoryID, "category": category,
|
||||||
|
"is_ap": ap, "is_ap_plus": 0, "is_ufi": 0, "is_bundleable": 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
// outfitCardRow is a community product as the outfit card shows it: from the library or the shop's listing if the
|
||||||
|
// hub listed it, else from its pack.
|
||||||
|
func (p *Products) outfitCardRow(id string) (cardRow, bool) {
|
||||||
|
s := p.byProductID(id)
|
||||||
|
if s == nil {
|
||||||
|
return cardRow{}, false
|
||||||
|
}
|
||||||
|
if p.Library != nil {
|
||||||
|
for _, e := range p.Library() {
|
||||||
|
if e.Product.ID == id {
|
||||||
|
return cardRow{e.Product, s.local}, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m := s.pack.Manifest
|
||||||
|
p.mu.Lock()
|
||||||
|
hp, ok := p.shopSeen[id]
|
||||||
|
p.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
hp = hub.Product{ID: id, Name: m.Name, Creator: m.Creator, Rating: m.Rating}
|
||||||
|
}
|
||||||
|
return cardRow{hp, s.local}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// interceptFindBundle asks IMVU about the outfit's IMVU products only (find_bundle.php?pids=1,2,...).
|
||||||
|
func (p *Products) interceptFindBundle(x *proxy.Exchange) error {
|
||||||
|
parts := strings.Split(x.URL.RawQuery, "&")
|
||||||
|
changed := false
|
||||||
|
for i, part := range parts {
|
||||||
|
name, value, ok := strings.Cut(part, "=")
|
||||||
|
if !ok || name != "pids" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var kept []string
|
||||||
|
for _, f := range strings.Split(value, ",") {
|
||||||
|
if id, ok := parseID(f); ok && IsCommunity(id) {
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, f)
|
||||||
|
}
|
||||||
|
parts[i] = name + "=" + strings.Join(kept, ",")
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
query := strings.Join(parts, "&")
|
||||||
|
if !p.keys.resign(x.Request.Header, x.Body, x.URL.RawQuery, x.Body, query) {
|
||||||
|
return errNoKey
|
||||||
|
}
|
||||||
|
x.URL.RawQuery = query
|
||||||
|
x.Request.URL.RawQuery = query
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// outfitRemovals takes community products out of an outfit card save's remove_pids, and out of the outfit as
|
||||||
|
// remembered here. It reports whether the body changed.
|
||||||
|
func (p *Products) outfitRemovals(save map[string]json.RawMessage) bool {
|
||||||
|
var remove []json.RawMessage
|
||||||
|
if json.Unmarshal(save["remove_pids"], &remove) != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var kept []json.RawMessage
|
||||||
|
var gone []string
|
||||||
|
for _, raw := range remove {
|
||||||
|
if id, ok := rawID(raw); ok && IsCommunity(id) {
|
||||||
|
if pid, ok := p.state.productID(id); ok {
|
||||||
|
gone = append(gone, pid)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, raw)
|
||||||
|
}
|
||||||
|
if len(kept) == len(remove) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if kept == nil {
|
||||||
|
kept = []json.RawMessage{}
|
||||||
|
}
|
||||||
|
save["remove_pids"], _ = json.Marshal(kept)
|
||||||
|
var outfit json.Number
|
||||||
|
if json.Unmarshal(save["outfit_id"], &outfit) != nil {
|
||||||
|
var s string
|
||||||
|
json.Unmarshal(save["outfit_id"], &s)
|
||||||
|
outfit = json.Number(s)
|
||||||
|
}
|
||||||
|
if outfit != "" && len(gone) > 0 {
|
||||||
|
var left []string
|
||||||
|
for _, pid := range p.state.outfit(outfit.String()) {
|
||||||
|
if !contains(gone, pid) {
|
||||||
|
left = append(left, pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := p.state.setOutfit(outfit.String(), left); err != nil {
|
||||||
|
p.logf("products: remembering an outfit: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(list []string, s string) bool {
|
||||||
|
for _, v := range list {
|
||||||
|
if v == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package products
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOutfitCard(t *testing.T) {
|
||||||
|
h, _ := newHarness(t)
|
||||||
|
local := h.p.byProductID("blue-coat").local
|
||||||
|
h.do("POST", "https://secure.imvu.com/api/xmlrpc/client.php", call("test.avatarInfoForLogin2"), false, false)
|
||||||
|
save := []byte(fmt.Sprintf(`{"outfit_name": "Mine", "products": "80,1,%d"}`, local))
|
||||||
|
h.do("POST", "https://client-dynamic.imvu.com/api/outfits.php", save, true, false)
|
||||||
|
|
||||||
|
// The product list has the community product too, not for sale.
|
||||||
|
_, body := h.do("GET", "https://client-dynamic.imvu.com/api/outfit_products.php?outfit_id=555", nil, true, true)
|
||||||
|
var r struct{ Result map[string]map[string]any }
|
||||||
|
if json.Unmarshal(body, &r) != nil || len(r.Result) != 2 {
|
||||||
|
t.Fatalf("products %s", body)
|
||||||
|
}
|
||||||
|
if e := r.Result[fmt.Sprint(local)]; e == nil || e["name"] != "Blue Coat" || e["is_bundleable"] != float64(0) {
|
||||||
|
t.Fatalf("community entry %v", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bundle check is asked about IMVU's products only (re-signed).
|
||||||
|
h.do("GET", fmt.Sprintf("https://client-dynamic.imvu.com/api/find_bundle.php?pids=80,%d.0,1", local), nil, true, true)
|
||||||
|
if got := h.imvu.asked["find bundle"]; len(got) != 1 || got[0] != "pids=80,1" {
|
||||||
|
t.Fatalf("find_bundle: IMVU got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removing it in the card: IMVU hears of IMVU's removals only, and the outfit forgets it.
|
||||||
|
h.do("POST", "https://client-dynamic.imvu.com/api/outfits.php",
|
||||||
|
[]byte(fmt.Sprintf(`{"outfit_id": "555", "outfit_name": "Mine", "remove_pids": [1, %d]}`, local)), true, false)
|
||||||
|
if got := h.imvu.asked["save outfit"]; len(got) != 2 || !strings.Contains(got[1], `"remove_pids":[1]`) {
|
||||||
|
t.Fatalf("IMVU got %q", got)
|
||||||
|
}
|
||||||
|
if left := h.p.state.outfit("555"); len(left) != 0 {
|
||||||
|
t.Fatalf("outfit still has %v", left)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
// 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"
|
||||||
|
"context"
|
||||||
|
"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"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/imq"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// Observer, if set, hears which IMVU account the client signs in as, the community products it wears, and the
|
||||||
|
// chats it's in (the hub client).
|
||||||
|
Observer Observer
|
||||||
|
|
||||||
|
// Want, if set, is asked for products someone in a room wears that aren't here (the fetcher gets hub products).
|
||||||
|
Want func(id string)
|
||||||
|
// HubDir is the pack root of products from the hub; of those, only ones Owned says are in the person's library
|
||||||
|
// are in the inventory (the rest are there because someone in a room wears them). Local packs always are.
|
||||||
|
HubDir string
|
||||||
|
Owned func(id string) bool
|
||||||
|
// OnHide, if set, hears that the person hid a community product in the inventory (removing it from their
|
||||||
|
// library, for hub products).
|
||||||
|
OnHide func(id string)
|
||||||
|
|
||||||
|
// Shop, if set, searches the hub's products for the shop's T5 filter; Fetch gets one into the hub's products
|
||||||
|
// now; AddToLibrary adds one to the person's library (buying it in the shop).
|
||||||
|
Shop func(ctx context.Context, query string) ([]hub.Product, error)
|
||||||
|
Fetch func(ctx context.Context, id string) error
|
||||||
|
AddToLibrary func(ctx context.Context, id string) error
|
||||||
|
// Library, if set, is the person's hub library as last read (for the inventory's Recently Added).
|
||||||
|
Library func() []hub.LibraryEntry
|
||||||
|
// Decorations and SaveDecorations, if set, get and store an IMVU room's decorations on the hub (decor.go).
|
||||||
|
Decorations func(ctx context.Context, instance string) ([]DecorItem, error)
|
||||||
|
SaveDecorations func(ctx context.Context, instance string, items []DecorItem) error
|
||||||
|
decor decorations
|
||||||
|
|
||||||
|
// Hosted, if set, serves T5DE-hosted rooms' queues (the hosted package).
|
||||||
|
Hosted HostedRooms
|
||||||
|
|
||||||
|
roomMu sync.Mutex
|
||||||
|
room room
|
||||||
|
|
||||||
|
user string // the signed-in IMVU account (from the login answer), under mu
|
||||||
|
shopSeen map[string]hub.Product // hub products as the shop last listed them, under mu
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ready is told a product arrived (the fetcher wrote it): it's served from now on, and given to the client for
|
||||||
|
// anyone in a room who wears it.
|
||||||
|
func (p *Products) Ready(id string) {
|
||||||
|
p.refresh(true)
|
||||||
|
p.roomMu.Lock()
|
||||||
|
p.room.init()
|
||||||
|
var chats []string
|
||||||
|
for chat, avatars := range p.room.last {
|
||||||
|
for _, a := range avatars {
|
||||||
|
for _, it := range a.Items {
|
||||||
|
if it.ID == id {
|
||||||
|
chats = append(chats, chat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.roomMu.Unlock()
|
||||||
|
for _, chat := range chats {
|
||||||
|
p.roomMu.Lock()
|
||||||
|
avatars := p.room.last[chat]
|
||||||
|
p.roomMu.Unlock()
|
||||||
|
p.Others(chat, avatars)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostedBase is where hosted rooms' numbers start (above it; hosted.Base).
|
||||||
|
const hostedBase = 2100000000
|
||||||
|
|
||||||
|
// HostedRooms serves T5DE-hosted rooms' IMQ queues: Route answers subscriptions to them, Send takes the client's
|
||||||
|
// messages to them (message as for others, original as the client sent it, for the echo; keep: nothing to send
|
||||||
|
// others), reporting whether the queue was one; Closed forgets a connection.
|
||||||
|
type HostedRooms interface {
|
||||||
|
Route(c *proxy.IMQConn, unsubscribe bool, s imq.Subscription) imq.Subscription
|
||||||
|
Send(queue, mount string, message, original []byte, keep bool) bool
|
||||||
|
Closed(c *proxy.IMQConn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route serves hosted rooms' queues (proxy.IMQRouter).
|
||||||
|
func (p *Products) Route(c *proxy.IMQConn, unsubscribe bool, s imq.Subscription) imq.Subscription {
|
||||||
|
if p.Hosted == nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return p.Hosted.Route(c, unsubscribe, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observer hears about the client's IMVU account, its community outfit, and the chats it's in.
|
||||||
|
type Observer interface {
|
||||||
|
Session(imvuID int64)
|
||||||
|
Outfit(imvuID int64, items []hub.Item)
|
||||||
|
Join(chat, room string)
|
||||||
|
Leave(chat string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() || strings.HasPrefix(e.Name(), ".") { // hidden: the fetcher's work in progress
|
||||||
|
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(versionKey(pk))
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// versionKey names a version of a product for its local ID: the revision and the info the client shows. The client
|
||||||
|
// keeps product info for good (its product info cache), so a product whose files or info changed gets a new local
|
||||||
|
// ID, and with it fresh info.
|
||||||
|
func versionKey(pk *pack.Pack) string {
|
||||||
|
m := pk.Manifest
|
||||||
|
sum := md5.Sum([]byte(strings.Join([]string{m.Name, m.Creator, m.Rating}, "|")))
|
||||||
|
return fmt.Sprintf("%s@%d.%s", m.ID, m.Revision, hex.EncodeToString(sum[:4]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
p.reportOutfit(user, locals)
|
||||||
|
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)")
|
||||||
|
|
||||||
|
// reportOutfit tells the observer the community products (local IDs) the user wears.
|
||||||
|
func (p *Products) reportOutfit(user string, locals []int64) {
|
||||||
|
if p.Observer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(user, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := []hub.Item{}
|
||||||
|
for _, local := range locals {
|
||||||
|
if s := p.lookup(local); s != nil {
|
||||||
|
items = append(items, hub.Item{ID: s.pack.Manifest.ID, Name: s.pack.Manifest.Name, Adult: s.pack.Manifest.Rating == "adult"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Observer.Outfit(id, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalID is the client's local ID for a product (a hub product ID or a local pack's), if it's here.
|
||||||
|
func (p *Products) LocalID(id string) (int64, bool) {
|
||||||
|
if s := p.byProductID(id); s != nil {
|
||||||
|
return s.local, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductID is the product a local ID was given to.
|
||||||
|
func (p *Products) ProductID(local int64) (string, bool) { return p.state.productID(local) }
|
||||||
|
|
||||||
|
// User is the IMVU account the client signed in as (0 before it has).
|
||||||
|
func (p *Products) User() int64 {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
id, _ := strconv.ParseInt(p.user, 10, 64)
|
||||||
|
return id
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if participantPath.MatchString(r.URL.Path) {
|
||||||
|
checkSigned(false)
|
||||||
|
f.record("participant", body)
|
||||||
|
return reply(`{"status": "success"}`)
|
||||||
|
}
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/outfit_products.php":
|
||||||
|
return reply(`{"result": {"80": {"pid": 80, "name": "Body", "is_bundleable": true}}, "bundle_id": 0}`)
|
||||||
|
case "/api/find_bundle.php":
|
||||||
|
checkSigned(true)
|
||||||
|
f.record("find bundle", []byte(r.URL.RawQuery))
|
||||||
|
return reply(`{"result": 0}`)
|
||||||
|
case "/api/recently_added_inventory.php":
|
||||||
|
return reply(`[31162856, 108363]`)
|
||||||
|
case "/api/shop/product.php":
|
||||||
|
checkSigned(false)
|
||||||
|
f.record("product info", body)
|
||||||
|
return reply(`{"products": [{"id": "12345", "name": "IMVU's"}]}`)
|
||||||
|
case "/api/shop/search_st.php":
|
||||||
|
f.record("search", body)
|
||||||
|
return reply(`{"keys": [], "result": {}}`)
|
||||||
|
case "/api/service/shop/svc_catalog_purchase.php":
|
||||||
|
checkSigned(false)
|
||||||
|
f.record("purchase", body)
|
||||||
|
return reply(`{"status": 1, "balance_credits": 10, "balance_promo": 0}`)
|
||||||
|
case "/api/outfits.php":
|
||||||
|
if len(body) > 0 {
|
||||||
|
checkSigned(false)
|
||||||
|
f.record("save outfit", body)
|
||||||
|
return reply(`{"result": {"outfit_id": 555}}`)
|
||||||
|
}
|
||||||
|
return reply(`{"result": [{"user_outfits_id": "555", "pids": [80, 1], "restricted": "0"}, {"user_outfits_id": "9", "pids": [80]}]}`)
|
||||||
|
case "/api/xmlrpc/chat.php":
|
||||||
|
f.record("chat", body)
|
||||||
|
return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{xmlrpc.Struct{
|
||||||
|
{Name: "chatId", Value: int64(555)}, {Name: "participantUrl", Value: "https://api.imvu.com/chat/chat-555/participants/user-7"}}}}).Marshal()))
|
||||||
|
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.setRoomState":
|
||||||
|
checkSigned(false)
|
||||||
|
return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{xmlrpc.Struct{{Name: "state_changed", Value: true}, {Name: "room_hash", Value: "h"}}}}).Marshal()))
|
||||||
|
case "test.getRoomState":
|
||||||
|
return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{xmlrpc.Struct{{Name: "room_state", Value: xmlrpc.Struct{
|
||||||
|
{Name: "room_info", Value: xmlrpc.Struct{{Name: "customers_id", Value: "7"}, {Name: "room_instance_id", Value: "7-1"}, {Name: "room_pid", Value: "300"}}},
|
||||||
|
{Name: "room_contents", Value: []xmlrpc.Value{[]xmlrpc.Value{int64(10), int64(301), "2-1"}}}}},
|
||||||
|
{Name: "room_hash", Value: "h"}}}}).Marshal()))
|
||||||
|
case "test.showInventory":
|
||||||
|
return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{[]xmlrpc.Value{int64(1), int64(2)}}}).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)
|
||||||
|
}
|
||||||
|
// Names lose characters the client's pages could take as HTML.
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInventory(t *testing.T) {
|
||||||
|
h, packs := newHarness(t)
|
||||||
|
hubDir := t.TempDir()
|
||||||
|
writePack(t, hubDir, "abcdefgh23", 1) // a hub product, kept by the fetcher
|
||||||
|
writePack(t, hubDir, "zzzzzzzz22", 1) // one someone in a room wore, not in the library
|
||||||
|
h.p.dirs = append(h.p.dirs, hubDir)
|
||||||
|
h.p.HubDir = hubDir
|
||||||
|
h.p.Owned = func(id string) bool { return id == "abcdefgh23" }
|
||||||
|
var hidden []string
|
||||||
|
h.p.OnHide = func(id string) { hidden = append(hidden, id) }
|
||||||
|
h.p.refresh(true)
|
||||||
|
local, hubLocal := h.p.byProductID("blue-coat").local, h.p.byProductID("abcdefgh23").local
|
||||||
|
|
||||||
|
api := "http://client-dynamic.imvu.com/api/xmlrpc/client.php"
|
||||||
|
_, body := h.do("POST", api, call("test.showInventory", int64(7), []xmlrpc.Value{int64(495)}, false), true, false)
|
||||||
|
r, err := xmlrpc.ParseResponse(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := fmt.Sprint(r.Params[0])
|
||||||
|
if want := fmt.Sprint([]xmlrpc.Value{int64(1), int64(2), local, hubLocal}); got != want {
|
||||||
|
t.Fatalf("inventory %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hiding a community product is answered here (and for hub products, takes them out of the library).
|
||||||
|
status, body := h.do("POST", "http://client-dynamic.imvu.com/api/service/inventory/inventory_delete.php",
|
||||||
|
[]byte(fmt.Sprintf("action=delete&cid=7&pid=%d", hubLocal)), true, false)
|
||||||
|
if status != 200 || !strings.Contains(string(body), `"success": true`) || len(hidden) != 1 || hidden[0] != "abcdefgh23" {
|
||||||
|
t.Fatalf("hide: %d %s %v", status, body, hidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A product whose info changes gets a new local ID, so the client's product info cache shows the new info.
|
||||||
|
dir := filepath.Join(packs, "blue-coat")
|
||||||
|
manifest := `{"format": 1, "id": "blue-coat", "revision": 1, "name": "Red Coat", "creator": "Tester", "rating": "general"}`
|
||||||
|
os.WriteFile(filepath.Join(dir, "t5de-product.json"), []byte(manifest), 0o644)
|
||||||
|
h.p.refresh(true)
|
||||||
|
if again := h.p.byProductID("blue-coat").local; again == local {
|
||||||
|
t.Fatal("same local ID after a rename")
|
||||||
|
} else if id, _ := h.p.state.productID(again); id != "blue-coat" {
|
||||||
|
t.Fatalf("product of the new local ID: %q", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSavedOutfits(t *testing.T) {
|
||||||
|
h, _ := newHarness(t)
|
||||||
|
local := h.p.byProductID("blue-coat").local
|
||||||
|
h.do("POST", "https://secure.imvu.com/api/xmlrpc/client.php", call("test.avatarInfoForLogin2"), false, false)
|
||||||
|
|
||||||
|
// Saving: IMVU gets the outfit without the community product.
|
||||||
|
// (2100000000 is a community ID the proxy doesn't know: it's kept from IMVU all the same.)
|
||||||
|
save := []byte(fmt.Sprintf(`{"outfit_name": "Mine", "products": "80,1,%d,2100000000", "privacy": 1}`, local))
|
||||||
|
if status, _ := h.do("POST", "https://client-dynamic.imvu.com/api/outfits.php", save, true, false); status != 200 {
|
||||||
|
t.Fatalf("status %d", status)
|
||||||
|
}
|
||||||
|
if got := h.imvu.asked["save outfit"]; len(got) != 1 || !strings.Contains(got[0], `"products":"80,1"`) {
|
||||||
|
t.Fatalf("IMVU got %q", got)
|
||||||
|
}
|
||||||
|
// Loading saved outfits: it's back in that outfit only.
|
||||||
|
_, body := h.do("GET", "https://client-dynamic.imvu.com/api/outfits.php?batch=555,9", nil, false, false)
|
||||||
|
var r struct {
|
||||||
|
Result []struct {
|
||||||
|
ID string `json:"user_outfits_id"`
|
||||||
|
Pids []int64 `json:"pids"`
|
||||||
|
} `json:"result"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &r); err != nil {
|
||||||
|
t.Fatal(err, string(body))
|
||||||
|
}
|
||||||
|
if fmt.Sprint(r.Result[0].Pids) != fmt.Sprint([]int64{80, 1, local}) || fmt.Sprint(r.Result[1].Pids) != "[80]" {
|
||||||
|
t.Fatalf("outfits %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package products
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The inventory's Recently Added (api/recently_added_inventory.php?type=clothing|furni|scenes) is a list of product
|
||||||
|
// IDs from IMVU; T5 products the person added to their library lately go first, newest first.
|
||||||
|
|
||||||
|
// recentFor is how long a T5 product counts as recently added.
|
||||||
|
const recentFor = 30 * 24 * time.Hour
|
||||||
|
|
||||||
|
// IMVU's categories for furniture and rooms (the inventory's FURNI_CATEGORIES and ROOM_CATEGORIES, and rooms' 107).
|
||||||
|
var (
|
||||||
|
furniCategories = map[int64]bool{1027: true, 110: true}
|
||||||
|
roomCategories = map[int64]bool{107: true, 109: true, 366: true, 626: true, 2386: true}
|
||||||
|
)
|
||||||
|
|
||||||
|
// inventoryType is which Recently Added list a product belongs in: clothing (anything worn), furni or scenes.
|
||||||
|
func inventoryType(categories []hub.Category) string {
|
||||||
|
for _, c := range categories {
|
||||||
|
if furniCategories[c.ID] {
|
||||||
|
return "furni"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range categories {
|
||||||
|
if roomCategories[c.ID] {
|
||||||
|
return "scenes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "clothing"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Products) interceptRecent(x *proxy.Exchange) error {
|
||||||
|
if p.Library == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
kind := x.URL.Query().Get("type")
|
||||||
|
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
|
||||||
|
var ids []json.RawMessage
|
||||||
|
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &ids) != nil {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
ours := p.recentLocals(kind, time.Now())
|
||||||
|
if len(ours) == 0 {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
out := make([]json.RawMessage, 0, len(ours)+len(ids))
|
||||||
|
for _, local := range ours {
|
||||||
|
out = append(out, json.RawMessage(strconv.FormatInt(local, 10)))
|
||||||
|
}
|
||||||
|
return json.Marshal(append(out, ids...))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// recentLocals is the local IDs of T5 products of that kind added to the library within recentFor, newest first.
|
||||||
|
func (p *Products) recentLocals(kind string, now time.Time) []int64 {
|
||||||
|
lib := p.Library()
|
||||||
|
sort.SliceStable(lib, func(i, j int) bool { return lib[i].AddedAt.After(lib[j].AddedAt) })
|
||||||
|
var out []int64
|
||||||
|
for _, e := range lib {
|
||||||
|
if now.Sub(e.AddedAt) > recentFor || inventoryType(e.Product.Categories) != kind {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s := p.byProductID(e.Product.ID); s != nil && p.inHubDir(e.Product.ID) {
|
||||||
|
out = append(out, s.local)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package products
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRecentlyAdded(t *testing.T) {
|
||||||
|
h, _ := newHarness(t)
|
||||||
|
hubDir := t.TempDir()
|
||||||
|
for _, id := range []string{"coatcoat22", "sofasofa22", "oldoldold2", "hairhair22"} {
|
||||||
|
writePack(t, hubDir, id, 1)
|
||||||
|
}
|
||||||
|
h.p.dirs = append(h.p.dirs, hubDir)
|
||||||
|
h.p.HubDir = hubDir
|
||||||
|
h.p.Owned = func(string) bool { return true }
|
||||||
|
h.p.refresh(true)
|
||||||
|
now := time.Now()
|
||||||
|
clothes := []hub.Category{{ID: 106}, {ID: 40}, {ID: 716}}
|
||||||
|
h.p.Library = func() []hub.LibraryEntry {
|
||||||
|
return []hub.LibraryEntry{
|
||||||
|
{Product: hub.Product{ID: "coatcoat22", Categories: clothes}, AddedAt: now.Add(-2 * time.Hour)},
|
||||||
|
{Product: hub.Product{ID: "sofasofa22", Categories: []hub.Category{{ID: 1027}, {ID: 1056}}}, AddedAt: now.Add(-time.Hour)},
|
||||||
|
{Product: hub.Product{ID: "oldoldold2", Categories: clothes}, AddedAt: now.Add(-40 * 24 * time.Hour)},
|
||||||
|
{Product: hub.Product{ID: "hairhair22"}, AddedAt: now.Add(-time.Hour)}, // no categories known: worn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
local := func(id string) int64 { return h.p.byProductID(id).local }
|
||||||
|
get := func(kind string) []int64 {
|
||||||
|
_, b := h.do("GET", "http://client-dynamic.imvu.com/api/recently_added_inventory.php?type="+kind, nil, false, false)
|
||||||
|
var ids []int64
|
||||||
|
if json.Unmarshal(b, &ids) != nil {
|
||||||
|
t.Fatalf("%s: %s", kind, b)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
if got, want := fmt.Sprint(get("clothing")), fmt.Sprint([]int64{local("hairhair22"), local("coatcoat22"), 31162856, 108363}); got != want {
|
||||||
|
t.Errorf("clothing %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
if got, want := fmt.Sprint(get("furni")), fmt.Sprint([]int64{local("sofasofa22"), 31162856, 108363}); got != want {
|
||||||
|
t.Errorf("furni %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
if got, want := fmt.Sprint(get("scenes")), fmt.Sprint([]int64{31162856, 108363}); got != want {
|
||||||
|
t.Errorf("scenes %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShopSorting(t *testing.T) {
|
||||||
|
day := func(n int) time.Time { return time.Date(2026, 10, n, 0, 0, 0, 0, time.UTC) }
|
||||||
|
list := func() []hub.Product {
|
||||||
|
return []hub.Product{
|
||||||
|
{ID: "a", Name: "Long Red Coat", CreatedAt: day(5)},
|
||||||
|
{ID: "b", Name: "Coat", CreatedAt: day(1)},
|
||||||
|
{ID: "c", Name: "Coats & Jackets Pack", CreatedAt: day(2)},
|
||||||
|
{ID: "d", Name: "Raincoat", CreatedAt: day(4)},
|
||||||
|
{ID: "e", Name: "Hat", Creator: "coat_maker", CreatedAt: day(3)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
order := func(l []hub.Product) string {
|
||||||
|
s := ""
|
||||||
|
for _, p := range l {
|
||||||
|
s += p.ID
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
for _, c := range []struct{ query, field, dir, want string }{
|
||||||
|
{"coat", "conversion_score_b", "desc", "bcade"}, // same name, starts with, word, inside a word, creator
|
||||||
|
{"", "conversion_score_b", "desc", "adecb"}, // no search: newest
|
||||||
|
{"coat", "id", "desc", "adecb"}, // Newest Products
|
||||||
|
{"coat", "id", "asc", "bceda"},
|
||||||
|
{"coat", "price", "asc", "adecb"}, // all free: newest
|
||||||
|
{"coat", "user_rating", "desc", "adecb"},
|
||||||
|
} {
|
||||||
|
l := list()
|
||||||
|
sortShop(l, c.query, c.field, c.dir)
|
||||||
|
if got := order(l); got != c.want {
|
||||||
|
t.Errorf("%q %s %s: %s, want %s", c.query, c.field, c.dir, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
package products
|
||||||
|
|
||||||
|
// The room channel: other T5DE users' community products, on their avatars in this client.
|
||||||
|
//
|
||||||
|
// IMVU never sees community products, so other people's clients get each avatar's outfit without them. The hub
|
||||||
|
// tells the proxy which T5DE users are in the same chat and what they wear (by product ID); for products the proxy
|
||||||
|
// has, it delivers "*use" and "*remove" lines from those users to the client, as if they had sent them, and adds the
|
||||||
|
// products to the whole outfits those users send (which would otherwise put IMVU's products back in their place).
|
||||||
|
// The client then asks for the products like any other, and the proxy answers for them.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/imq"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/xmlrpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// room is the room channel's state, under Products.roomMu.
|
||||||
|
type room struct {
|
||||||
|
subs map[*proxy.IMQConn]map[string]bool // chat IDs each IMQ connection is subscribed to
|
||||||
|
rooms map[string]string // chat ID -> room instance, from getOrMakeChat
|
||||||
|
applied map[string]map[int64][]int64 // chat -> user -> local IDs the client was given for them
|
||||||
|
userIDs map[int64]string // user -> the gateway's user_id for them, as last seen
|
||||||
|
missing map[string]bool // products others wear that aren't in the packs (logged once)
|
||||||
|
last map[string][]hub.Avatar // chat -> what the hub last said about the others in it
|
||||||
|
conns map[*proxy.IMQConn]bool // the client's IMQ connections
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *room) init() {
|
||||||
|
if r.subs == nil {
|
||||||
|
r.subs = map[*proxy.IMQConn]map[string]bool{}
|
||||||
|
r.rooms = map[string]string{}
|
||||||
|
r.applied = map[string]map[int64][]int64{}
|
||||||
|
r.userIDs = map[int64]string{}
|
||||||
|
r.missing = map[string]bool{}
|
||||||
|
r.last = map[string][]hub.Avatar{}
|
||||||
|
r.conns = map[*proxy.IMQConn]bool{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatOf is the chat ID of an IMQ chat queue ("/chat/<id>"), or "".
|
||||||
|
func chatOf(queue string) string {
|
||||||
|
id, ok := strings.CutPrefix(queue, "/chat/")
|
||||||
|
if !ok || id == "" || strings.Trim(id, "0123456789") != "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// inChat reports whether any connection is subscribed to chat (under roomMu).
|
||||||
|
func (r *room) inChat(chat string) bool {
|
||||||
|
for _, chats := range r.subs {
|
||||||
|
if chats[chat] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribed implements proxy.IMQWatcher: chats the client joins are reported to the hub.
|
||||||
|
func (p *Products) Subscribed(c *proxy.IMQConn, queues []string) {
|
||||||
|
p.roomMu.Lock()
|
||||||
|
p.room.init()
|
||||||
|
var joined []string
|
||||||
|
var rooms []string
|
||||||
|
for _, q := range queues {
|
||||||
|
chat := chatOf(q)
|
||||||
|
if chat == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !p.room.inChat(chat) {
|
||||||
|
joined, rooms = append(joined, chat), append(rooms, p.room.rooms[chat])
|
||||||
|
}
|
||||||
|
if p.room.subs[c] == nil {
|
||||||
|
p.room.subs[c] = map[string]bool{}
|
||||||
|
}
|
||||||
|
p.room.subs[c][chat] = true
|
||||||
|
}
|
||||||
|
p.roomMu.Unlock()
|
||||||
|
if p.Observer != nil {
|
||||||
|
for i, chat := range joined {
|
||||||
|
p.Observer.Join(chat, rooms[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribed implements proxy.IMQWatcher.
|
||||||
|
func (p *Products) Unsubscribed(c *proxy.IMQConn, queues []string) {
|
||||||
|
p.roomMu.Lock()
|
||||||
|
p.room.init()
|
||||||
|
var left []string
|
||||||
|
for _, q := range queues {
|
||||||
|
if chat := chatOf(q); chat != "" && p.room.subs[c][chat] {
|
||||||
|
delete(p.room.subs[c], chat)
|
||||||
|
if !p.room.inChat(chat) {
|
||||||
|
left = append(left, chat)
|
||||||
|
delete(p.room.applied, chat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.roomMu.Unlock()
|
||||||
|
if p.Observer != nil {
|
||||||
|
for _, chat := range left {
|
||||||
|
p.Observer.Leave(chat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closed implements proxy.IMQWatcher: a connection's subscriptions end with it.
|
||||||
|
func (p *Products) Closed(c *proxy.IMQConn) {
|
||||||
|
if p.Hosted != nil {
|
||||||
|
p.Hosted.Closed(c)
|
||||||
|
}
|
||||||
|
p.roomMu.Lock()
|
||||||
|
p.room.init()
|
||||||
|
var queues []string
|
||||||
|
for chat := range p.room.subs[c] {
|
||||||
|
queues = append(queues, "/chat/"+chat)
|
||||||
|
}
|
||||||
|
p.roomMu.Unlock()
|
||||||
|
p.Unsubscribed(c, queues)
|
||||||
|
p.roomMu.Lock()
|
||||||
|
delete(p.room.subs, c)
|
||||||
|
delete(p.room.conns, c)
|
||||||
|
p.roomMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// userOf is the user ID in the gateway's user_id ("123" or ".../123").
|
||||||
|
func userOf(userID string) int64 {
|
||||||
|
i := strings.LastIndexByte(userID, '/')
|
||||||
|
id, err := strconv.ParseInt(userID[i+1:], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incoming implements proxy.IMQWatcher: a T5DE user's whole outfit gets their community products added.
|
||||||
|
func (p *Products) Incoming(c *proxy.IMQConn, m imq.Delivered) []byte {
|
||||||
|
chat := chatOf(m.Queue)
|
||||||
|
if chat != "" && m.Mount == "control" {
|
||||||
|
// IMVU's server sends everyone a room its owner saved (as IMVU has it: without decorations).
|
||||||
|
if out, changed := p.incomingControlScene(m.Message); changed {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return m.Message
|
||||||
|
}
|
||||||
|
if chat == "" || m.Mount != "messages" {
|
||||||
|
return m.Message
|
||||||
|
}
|
||||||
|
user := userOf(m.UserID)
|
||||||
|
if user == 0 {
|
||||||
|
return m.Message
|
||||||
|
}
|
||||||
|
if bytes.Contains(m.Message, []byte(setRoomStateCommand)) {
|
||||||
|
if msg, ok := parseChatMessage(m.Message); ok && strings.HasPrefix(msg.text, setRoomStateCommand) {
|
||||||
|
if out, changed := p.incomingScene(msg); changed {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return m.Message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.roomMu.Lock()
|
||||||
|
p.room.init()
|
||||||
|
p.room.userIDs[user] = m.UserID
|
||||||
|
locals := slices.Clone(p.room.applied[chat][user])
|
||||||
|
p.roomMu.Unlock()
|
||||||
|
if len(locals) == 0 {
|
||||||
|
return m.Message
|
||||||
|
}
|
||||||
|
msg, ok := parseChatMessage(m.Message)
|
||||||
|
if !ok {
|
||||||
|
return m.Message
|
||||||
|
}
|
||||||
|
fields := strings.Fields(msg.text)
|
||||||
|
if len(fields) < 2 || fields[0] != "*use" || !slices.Contains(fields[1:], strconv.Itoa(bodyProduct)) {
|
||||||
|
return m.Message
|
||||||
|
}
|
||||||
|
for _, id := range locals {
|
||||||
|
if s := strconv.FormatInt(id, 10); !slices.Contains(fields, s) {
|
||||||
|
fields = append(fields, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msg.withText(strings.Join(fields, " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Others is the hub's word on the other T5DE users in a chat and what they wear (none: forget them). The client is
|
||||||
|
// given the community products it has packs for, and told to take off the ones no longer worn.
|
||||||
|
func (p *Products) Others(chat string, avatars []hub.Avatar) {
|
||||||
|
type line struct {
|
||||||
|
user int64
|
||||||
|
text string
|
||||||
|
}
|
||||||
|
want := map[int64][]int64{}
|
||||||
|
for _, a := range avatars {
|
||||||
|
var locals []int64
|
||||||
|
for _, it := range a.Items {
|
||||||
|
if s := p.byProductID(it.ID); s != nil {
|
||||||
|
locals = append(locals, s.local)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.Want != nil {
|
||||||
|
p.Want(it.ID) // given to the client when it arrives (Ready)
|
||||||
|
}
|
||||||
|
p.roomMu.Lock()
|
||||||
|
p.room.init()
|
||||||
|
first := !p.room.missing[it.ID]
|
||||||
|
p.room.missing[it.ID] = true
|
||||||
|
p.roomMu.Unlock()
|
||||||
|
if first {
|
||||||
|
p.logf("products: someone in the room wears %q (%s), which isn't here yet", it.Name, it.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.Sort(locals)
|
||||||
|
want[a.IMVUID] = locals
|
||||||
|
}
|
||||||
|
|
||||||
|
p.roomMu.Lock()
|
||||||
|
p.room.init()
|
||||||
|
if avatars == nil {
|
||||||
|
delete(p.room.applied, chat)
|
||||||
|
delete(p.room.last, chat)
|
||||||
|
p.roomMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.room.last[chat] = avatars
|
||||||
|
had := p.room.applied[chat]
|
||||||
|
var lines []line
|
||||||
|
for user, old := range had {
|
||||||
|
if _, still := want[user]; !still && len(old) > 0 {
|
||||||
|
lines = append(lines, line{user, "*remove " + joinIDs(old)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for user, locals := range want {
|
||||||
|
old := had[user]
|
||||||
|
var gone, added []int64
|
||||||
|
for _, id := range old {
|
||||||
|
if !slices.Contains(locals, id) {
|
||||||
|
gone = append(gone, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range locals {
|
||||||
|
if !slices.Contains(old, id) {
|
||||||
|
added = append(added, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(gone) > 0 {
|
||||||
|
lines = append(lines, line{user, "*remove " + joinIDs(gone)})
|
||||||
|
}
|
||||||
|
if len(added) > 0 {
|
||||||
|
lines = append(lines, line{user, "*use " + joinIDs(added)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.room.applied[chat] = want
|
||||||
|
var conns []*proxy.IMQConn
|
||||||
|
for c, chats := range p.room.subs {
|
||||||
|
if chats[chat] {
|
||||||
|
conns = append(conns, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
userIDs := map[int64]string{}
|
||||||
|
for _, l := range lines {
|
||||||
|
if id, ok := p.room.userIDs[l.user]; ok {
|
||||||
|
userIDs[l.user] = id
|
||||||
|
} else {
|
||||||
|
userIDs[l.user] = strconv.FormatInt(l.user, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.roomMu.Unlock()
|
||||||
|
|
||||||
|
chatID, _ := strconv.ParseInt(chat, 10, 64)
|
||||||
|
for _, l := range lines {
|
||||||
|
message, _ := json.Marshal(map[string]any{"userId": l.user, "chatId": chatID, "message": l.text, "to": 0})
|
||||||
|
for _, c := range conns {
|
||||||
|
if err := c.Deliver(imq.Delivered{UserID: userIDs[l.user], Queue: "/chat/" + chat, Mount: "messages", Message: message}); err != nil {
|
||||||
|
p.logf("products: giving the client %d's community products: %v", l.user, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(lines) > 0 {
|
||||||
|
p.logf("products: %d community product changes for others in chat %s", len(lines), chat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinIDs(ids []int64) string {
|
||||||
|
s := make([]string, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
s[i] = strconv.FormatInt(id, 10)
|
||||||
|
}
|
||||||
|
return strings.Join(s, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// interceptChat learns which room a chat is (chat.getOrMakeChat: asked with activity "publicroom-<room>",
|
||||||
|
// answered with the chat ID).
|
||||||
|
func (p *Products) interceptChat(x *proxy.Exchange) error {
|
||||||
|
call, err := xmlrpc.ParseCall(x.Body)
|
||||||
|
if err != nil || call.Method != "chat.getOrMakeChat" || len(call.Params) != 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
args, ok := call.Params[0].(xmlrpc.Struct)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
activity, _ := args.Get("activity")
|
||||||
|
a, _ := activity.(string)
|
||||||
|
inst, ok := strings.CutPrefix(a, "publicroom-")
|
||||||
|
if !ok || !roomInstance.MatchString(inst) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
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, nil
|
||||||
|
}
|
||||||
|
res, ok := r.Params[0].(xmlrpc.Struct)
|
||||||
|
if !ok {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
v, _ := res.Get("chatId")
|
||||||
|
var chat string
|
||||||
|
switch v := v.(type) {
|
||||||
|
case int64:
|
||||||
|
chat = strconv.FormatInt(v, 10)
|
||||||
|
case string:
|
||||||
|
chat = v
|
||||||
|
}
|
||||||
|
if chat != "" && strings.Trim(chat, "0123456789") == "" {
|
||||||
|
p.roomMu.Lock()
|
||||||
|
p.room.init()
|
||||||
|
p.room.rooms[chat] = inst
|
||||||
|
p.roomMu.Unlock()
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var roomInstance = regexp.MustCompile(`^[0-9]{1,12}-[0-9]{1,12}$`)
|
||||||
|
|
||||||
|
// participantPath is where the client posts its outfit and seat to IMVU while in a chat.
|
||||||
|
var participantPath = regexp.MustCompile(`^/chat/chat-[0-9]+/participants/user-[0-9]+$`)
|
||||||
|
|
||||||
|
// interceptParticipant keeps community products out of the outfit the client posts to IMVU's chat participant
|
||||||
|
// endpoint ({"legacy_outfit_message": "*use ...", ...}).
|
||||||
|
func (p *Products) interceptParticipant(x *proxy.Exchange) error {
|
||||||
|
if len(x.Body) == 0 || !bytes.Contains(x.Body, []byte("legacy_outfit_message")) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var post map[string]json.RawMessage
|
||||||
|
if json.Unmarshal(x.Body, &post) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var text string
|
||||||
|
if json.Unmarshal(post["legacy_outfit_message"], &text) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r := stripCommand(text, IsCommunity)
|
||||||
|
if !r.changed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if r.empty {
|
||||||
|
delete(post, "legacy_outfit_message")
|
||||||
|
} else {
|
||||||
|
post["legacy_outfit_message"], _ = json.Marshal(r.text)
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(post)
|
||||||
|
if !p.keys.resign(x.Request.Header, x.Body, x.URL.RawQuery, body, x.URL.RawQuery) {
|
||||||
|
return errNoKey
|
||||||
|
}
|
||||||
|
p.logf("products: kept %d community products out of the outfit posted to IMVU's chat", len(r.products))
|
||||||
|
x.Body = body
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opened implements proxy.IMQWatcher.
|
||||||
|
func (p *Products) Opened(c *proxy.IMQConn) {
|
||||||
|
p.roomMu.Lock()
|
||||||
|
defer p.roomMu.Unlock()
|
||||||
|
p.room.init()
|
||||||
|
p.room.conns[c] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshInventory has the client load its inventory again (after the library changed), with the server event
|
||||||
|
// IMVU sends after a purchase: "refreshInventory {}" on the user queue's ("/user/<id>") web_msg mount.
|
||||||
|
func (p *Products) RefreshInventory() {
|
||||||
|
if err := p.DeliverToUser("web_msg", []byte("refreshInventory {}")); err != nil {
|
||||||
|
p.logf("products: refreshing the inventory: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeliverToUser gives the client a message on its own user queue (/user/<id>), as from the gateway.
|
||||||
|
func (p *Products) DeliverToUser(mount string, message []byte) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
user := p.user
|
||||||
|
p.mu.Unlock()
|
||||||
|
if user == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p.roomMu.Lock()
|
||||||
|
p.room.init()
|
||||||
|
var conns []*proxy.IMQConn
|
||||||
|
for c := range p.room.conns {
|
||||||
|
conns = append(conns, c)
|
||||||
|
}
|
||||||
|
p.roomMu.Unlock()
|
||||||
|
var last error
|
||||||
|
for _, c := range conns {
|
||||||
|
if err := c.Deliver(imq.Delivered{UserID: "0", Queue: "/user/" + user, Mount: mount, Message: message}); err != nil {
|
||||||
|
last = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return last
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package products
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/imq"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/xmlrpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeObserver records what the hub client would be told.
|
||||||
|
type fakeObserver struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
events []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeObserver) add(e string) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.events = append(f.events, e)
|
||||||
|
}
|
||||||
|
func (f *fakeObserver) Session(int64) {}
|
||||||
|
func (f *fakeObserver) Outfit(int64, []hub.Item) {}
|
||||||
|
func (f *fakeObserver) Join(chat, room string) { f.add("join " + chat + " " + room) }
|
||||||
|
func (f *fakeObserver) Leave(chat string) { f.add("leave " + chat) }
|
||||||
|
|
||||||
|
// delivered reads the chat lines delivered to a client: "from: text".
|
||||||
|
func delivered(t *testing.T, b *bytes.Buffer) []string {
|
||||||
|
t.Helper()
|
||||||
|
var lines []string
|
||||||
|
for b.Len() > 0 {
|
||||||
|
f, n, err := imq.Next(b.Bytes())
|
||||||
|
if err != nil || n == 0 {
|
||||||
|
t.Fatalf("bad frames: %v", err)
|
||||||
|
}
|
||||||
|
b.Next(n)
|
||||||
|
m, err := imq.ParseDelivered(f.Data)
|
||||||
|
if err != nil || m.Queue != "/chat/555" || m.Mount != "messages" {
|
||||||
|
t.Fatalf("delivered %+v %v", m, err)
|
||||||
|
}
|
||||||
|
var msg struct {
|
||||||
|
Message string
|
||||||
|
UserID int64 `json:"userId"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(m.Message, &msg)
|
||||||
|
lines = append(lines, fmt.Sprintf("%s(%d): %s", m.UserID, msg.UserID, msg.Message))
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoomChannel(t *testing.T) {
|
||||||
|
h, _ := newHarness(t)
|
||||||
|
obs := &fakeObserver{}
|
||||||
|
h.p.Observer = obs
|
||||||
|
local := h.p.byProductID("blue-coat").local
|
||||||
|
|
||||||
|
// Joining a room: getOrMakeChat says which chat it is, then the client subscribes to its queue.
|
||||||
|
h.do("POST", "http://chat.imvu.com/api/xmlrpc/chat.php", call("chat.getOrMakeChat",
|
||||||
|
xmlrpc.Struct{{Name: "userId", Value: int64(7)}, {Name: "activity", Value: "publicroom-12-3"}}), false, false)
|
||||||
|
var toClient bytes.Buffer
|
||||||
|
c := proxy.NewIMQConn(1, &toClient)
|
||||||
|
h.p.Subscribed(c, []string{"/user/7", "/chat/555"})
|
||||||
|
if got := strings.Join(obs.events, "; "); got != "join 555 12-3" {
|
||||||
|
t.Fatalf("observer heard %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Someone else wears the coat (and something not in the packs): the client is given it from them.
|
||||||
|
h.p.Incoming(c, imq.Delivered{UserID: "/user/88", Queue: "/chat/555", Mount: "messages", Message: []byte(`{"message":"hi","userId":88}`)})
|
||||||
|
h.p.Others("555", []hub.Avatar{{IMVUID: 88, Items: []hub.Item{{ID: "blue-coat", Name: "Coat"}, {ID: "elsewhere", Name: "Hat"}}}})
|
||||||
|
if got := delivered(t, &toClient); len(got) != 1 || got[0] != fmt.Sprintf("/user/88(88): *use %d", local) {
|
||||||
|
t.Fatalf("delivered %q", got)
|
||||||
|
}
|
||||||
|
// Told again with no change: nothing new.
|
||||||
|
h.p.Others("555", []hub.Avatar{{IMVUID: 88, Items: []hub.Item{{ID: "blue-coat", Name: "Coat"}}}})
|
||||||
|
if toClient.Len() != 0 {
|
||||||
|
t.Fatal("delivered again")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Their whole outfit (which IMVU sends without it) gets it added; other lines don't.
|
||||||
|
whole := []byte(`{"message":"*use 80 1 2","userId":88,"chatId":555,"to":0}`)
|
||||||
|
out := h.p.Incoming(c, imq.Delivered{UserID: "/user/88", Queue: "/chat/555", Mount: "messages", Message: whole})
|
||||||
|
if !bytes.Contains(out, []byte(fmt.Sprintf(`"message":"*use 80 1 2 %d"`, local))) || !bytes.Contains(out, []byte(`"chatId":555`)) {
|
||||||
|
t.Fatalf("whole outfit %s", out)
|
||||||
|
}
|
||||||
|
part := []byte(`{"message":"*use 3","userId":88}`)
|
||||||
|
if out := h.p.Incoming(c, imq.Delivered{UserID: "/user/88", Queue: "/chat/555", Mount: "messages", Message: part}); !bytes.Equal(out, part) {
|
||||||
|
t.Fatalf("a single product changed: %s", out)
|
||||||
|
}
|
||||||
|
other := []byte(`{"message":"*use 80 1","userId":99}`)
|
||||||
|
if out := h.p.Incoming(c, imq.Delivered{UserID: "/user/99", Queue: "/chat/555", Mount: "messages", Message: other}); !bytes.Equal(out, other) {
|
||||||
|
t.Fatalf("someone else's outfit changed: %s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// They take it off, then leave: the client is told to take it off once.
|
||||||
|
h.p.Others("555", []hub.Avatar{{IMVUID: 88}})
|
||||||
|
h.p.Others("555", []hub.Avatar{})
|
||||||
|
if got := delivered(t, &toClient); len(got) != 1 || got[0] != fmt.Sprintf("/user/88(88): *remove %d", local) {
|
||||||
|
t.Fatalf("delivered %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The connection closing leaves the chat.
|
||||||
|
h.p.Closed(c)
|
||||||
|
if got := obs.events[len(obs.events)-1]; got != "leave 555" {
|
||||||
|
t.Fatalf("last event %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParticipantPostKeepsCommunityProducts(t *testing.T) {
|
||||||
|
h, _ := newHarness(t)
|
||||||
|
local := h.p.byProductID("blue-coat").local
|
||||||
|
h.do("POST", "https://secure.imvu.com/api/xmlrpc/client.php", call("test.avatarInfoForLogin2"), false, false)
|
||||||
|
body := []byte(fmt.Sprintf(`{"legacy_outfit_message": "*use 80 1 %d", "seat_number": 2}`, local))
|
||||||
|
if status, _ := h.do("POST", "https://api.imvu.com/chat/chat-555/participants/user-7", body, true, false); status != 200 {
|
||||||
|
t.Fatalf("status %d", status)
|
||||||
|
}
|
||||||
|
got := h.imvu.asked["participant"]
|
||||||
|
if len(got) != 1 || strings.Contains(got[0], fmt.Sprint(local)) || !strings.Contains(got[0], `"legacy_outfit_message":"*use 80 1"`) || !strings.Contains(got[0], `"seat_number":2`) {
|
||||||
|
t.Fatalf("IMVU was sent %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
package products
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The client's shop shows T5 products under its T5 filter (the t5de-shop patch), which searches with
|
||||||
|
// maturity_rating "t5": the proxy answers those searches from the hub instead of asking IMVU. Buying a T5 product
|
||||||
|
// adds it to the person's library on the hub; it's free, and IMVU never hears of it.
|
||||||
|
|
||||||
|
// ShopRating is the shop's maturity_rating for T5 products.
|
||||||
|
const ShopRating = "t5"
|
||||||
|
|
||||||
|
// shopKeys are search_st.php's columns, as IMVU sends them.
|
||||||
|
var shopKeys = []string{"id", "name", "price", "discount_price", "visible", "ap", "applus", "image", "creator_id",
|
||||||
|
"creator_name", "pro", "owns_product", "in_wishlist", "category", "category_name", "parent_categories",
|
||||||
|
"sub_pids", "index", "new", "sponsored", "purchasable"}
|
||||||
|
|
||||||
|
// shopFetchTime is how long a search waits for products to arrive from catalogs (the rest show on a later search).
|
||||||
|
var shopFetchTime = 15 * time.Second
|
||||||
|
|
||||||
|
func (p *Products) interceptShopSearch(x *proxy.Exchange) error {
|
||||||
|
var req struct {
|
||||||
|
Rating string `json:"maturity_rating"`
|
||||||
|
Search *string `json:"search"`
|
||||||
|
Category json.RawMessage `json:"category"`
|
||||||
|
Page json.Number `json:"page"`
|
||||||
|
Skip json.Number `json:"skip"`
|
||||||
|
PerPage json.Number `json:"results_per_page"`
|
||||||
|
Sort string `json:"sort_field"`
|
||||||
|
Dir string `json:"sort_direction"`
|
||||||
|
Limit json.Number `json:"solr_limit"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(x.Body, &req) != nil || req.Rating != ShopRating {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if p.Shop == nil {
|
||||||
|
x.Response = localResponse(200, "application/json", shopAnswer(nil, 1, 0, 12, 24))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), shopFetchTime+5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
query := ""
|
||||||
|
if req.Search != nil {
|
||||||
|
query = strings.TrimSpace(*req.Search)
|
||||||
|
}
|
||||||
|
list, err := p.Shop(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
p.logf("shop: searching the hub: %v", err)
|
||||||
|
x.Response = localResponse(502, "application/json", []byte(`{"error":"hub"}`))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
if p.shopSeen == nil {
|
||||||
|
p.shopSeen = map[string]hub.Product{}
|
||||||
|
}
|
||||||
|
for _, hp := range list {
|
||||||
|
p.shopSeen[hp.ID] = hp
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
list = inCategories(list, categoryIDs(req.Category))
|
||||||
|
sortShop(list, query, req.Sort, req.Dir)
|
||||||
|
p.fetchForShop(ctx, list)
|
||||||
|
|
||||||
|
var rows []shopRow
|
||||||
|
for _, hp := range list {
|
||||||
|
if s := p.byProductID(hp.ID); s != nil {
|
||||||
|
rows = append(rows, shopRow{Product: hp, local: s.local})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
page, skip, perPage, limit := number(req.Page, 1), number(req.Skip, 0), number(req.PerPage, 12), number(req.Limit, 24)
|
||||||
|
if perPage < 1 {
|
||||||
|
perPage = 12
|
||||||
|
}
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if skip < 0 {
|
||||||
|
skip = (page - 1) * perPage
|
||||||
|
}
|
||||||
|
for i := range rows {
|
||||||
|
rows[i].owned = p.owns(rows[i].ID)
|
||||||
|
}
|
||||||
|
x.Response = localResponse(200, "application/json", shopAnswer(rows, page, skip, perPage, limit))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type shopRow struct {
|
||||||
|
hub.Product
|
||||||
|
local int64
|
||||||
|
owned bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r shopRow) cells(index int) []any {
|
||||||
|
ap := 0
|
||||||
|
if r.Rating == "adult" {
|
||||||
|
ap = 1
|
||||||
|
}
|
||||||
|
category, categoryName := int64(0), ""
|
||||||
|
parents := []hub.Category{}
|
||||||
|
if n := len(r.Categories); n > 0 {
|
||||||
|
category, categoryName = r.Categories[n-1].ID, categoryText(r.Categories[n-1].Name)
|
||||||
|
for _, c := range r.Categories[:n-1] {
|
||||||
|
parents = append(parents, hub.Category{ID: c.ID, Name: categoryText(c.Name)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []any{strconv.FormatInt(r.local, 10), plain(r.Name), 0, 0, 1, ap, 0, r.Image, "0", plain(r.Creator), 0,
|
||||||
|
r.owned, 0, category, categoryName, parents, "", index, 0, 0, 1}
|
||||||
|
}
|
||||||
|
|
||||||
|
// categoryText is an IMVU category name (from the hub), kept to what plain allows plus "&" ("Coats & Jackets"):
|
||||||
|
// a lone "&" can't start markup.
|
||||||
|
func categoryText(s string) string {
|
||||||
|
parts := strings.Split(s, "&")
|
||||||
|
for i, part := range parts {
|
||||||
|
parts[i] = plain(part)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "&")
|
||||||
|
}
|
||||||
|
|
||||||
|
// owns reports whether the person has a product: local packs always, hub products in their library.
|
||||||
|
func (p *Products) owns(id string) bool {
|
||||||
|
if p.inHubDir(id) {
|
||||||
|
return p.Owned != nil && p.Owned(id)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// object is the row as one of product.php's products (keys to values).
|
||||||
|
func (r shopRow) object() map[string]any {
|
||||||
|
cells := r.cells(0)
|
||||||
|
out := map[string]any{}
|
||||||
|
for i, k := range shopKeys {
|
||||||
|
out[k] = cells[i]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// interceptProductInfo answers product.php's product details for community products, and asks IMVU only for its
|
||||||
|
// own.
|
||||||
|
func (p *Products) interceptProductInfo(x *proxy.Exchange) error {
|
||||||
|
var body map[string]json.RawMessage
|
||||||
|
if json.Unmarshal(x.Body, &body) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var pids []json.RawMessage
|
||||||
|
if json.Unmarshal(body["pids"], &pids) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var keep []json.RawMessage
|
||||||
|
var ours []any
|
||||||
|
community := 0
|
||||||
|
for _, raw := range pids {
|
||||||
|
id, ok := rawID(raw)
|
||||||
|
if !ok || !IsCommunity(id) {
|
||||||
|
keep = append(keep, raw)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
community++
|
||||||
|
if r, ok := p.shopRowFor(id); ok {
|
||||||
|
ours = append(ours, r.object())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if community == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(keep) == 0 {
|
||||||
|
b, _ := json.Marshal(map[string]any{"products": ours})
|
||||||
|
x.Response = localResponse(200, "application/json", b)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
body["pids"], _ = json.Marshal(keep)
|
||||||
|
nb, _ := json.Marshal(body)
|
||||||
|
if !p.keys.resign(x.Request.Header, x.Body, x.URL.RawQuery, nb, x.URL.RawQuery) {
|
||||||
|
return errNoKey
|
||||||
|
}
|
||||||
|
x.Body = nb
|
||||||
|
x.ModifyResponse = func(resp *http.Response, b []byte) ([]byte, error) {
|
||||||
|
var r map[string]json.RawMessage
|
||||||
|
var list []any
|
||||||
|
if json.Unmarshal(b, &r) != nil || json.Unmarshal(r["products"], &list) != nil {
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
r["products"], _ = json.Marshal(append(list, ours...))
|
||||||
|
return json.Marshal(r)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// shopRowFor is a community product's shop entry: as the hub listed it, else from its pack.
|
||||||
|
func (p *Products) shopRowFor(local int64) (shopRow, bool) {
|
||||||
|
s := p.lookup(local)
|
||||||
|
if s == nil {
|
||||||
|
return shopRow{}, false
|
||||||
|
}
|
||||||
|
m := s.pack.Manifest
|
||||||
|
p.mu.Lock()
|
||||||
|
hp, ok := p.shopSeen[m.ID]
|
||||||
|
p.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
hp = hub.Product{ID: m.ID, Name: m.Name, Creator: m.Creator, Rating: m.Rating, Latest: m.Revision}
|
||||||
|
}
|
||||||
|
return shopRow{Product: hp, local: s.local, owned: p.owns(m.ID)}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawID reads a product ID sent as a number or a string.
|
||||||
|
func rawID(raw json.RawMessage) (int64, bool) {
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(raw, &s) != nil {
|
||||||
|
var n json.Number
|
||||||
|
if json.Unmarshal(raw, &n) != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
s = n.String()
|
||||||
|
}
|
||||||
|
return parseID(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// shopAnswer is a search_st.php answer: the pages from page on, holding up to limit products from skip.
|
||||||
|
func shopAnswer(rows []shopRow, page, skip, perPage, limit int) []byte {
|
||||||
|
result := map[string][]any{}
|
||||||
|
end := min(skip+limit, len(rows))
|
||||||
|
for i := skip; i < end; i++ {
|
||||||
|
key := strconv.Itoa(page + (i-skip)/perPage)
|
||||||
|
result[key] = append(result[key], rows[i].cells(i))
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(map[string]any{
|
||||||
|
"keys": shopKeys,
|
||||||
|
"startIndex": skip,
|
||||||
|
"endIndex": end - 1,
|
||||||
|
"result": result,
|
||||||
|
"totalProductCount": len(rows),
|
||||||
|
"thisPageProductCount": max(end-skip, 0),
|
||||||
|
"pageCount": (len(rows) + perPage - 1) / perPage,
|
||||||
|
"thisPage": page,
|
||||||
|
})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortShop orders products for the shop's sort menu. T5 products have no ratings or prices, so those sorts are by
|
||||||
|
// newest; Best Match (conversion_score_b, score) with a search puts the closest names first (the same name, then
|
||||||
|
// names starting with it, then words starting with it, then the rest), newest first among equals; Newest
|
||||||
|
// (id) is newest first, or oldest first ascending.
|
||||||
|
func sortShop(list []hub.Product, query, field, direction string) {
|
||||||
|
q := strings.ToLower(strings.TrimSpace(query))
|
||||||
|
rank := func(hp hub.Product) int {
|
||||||
|
if q == "" || (field != "conversion_score_b" && field != "score" && field != "") {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
name := strings.ToLower(hp.Name)
|
||||||
|
switch {
|
||||||
|
case name == q:
|
||||||
|
return 0
|
||||||
|
case strings.HasPrefix(name, q):
|
||||||
|
return 1
|
||||||
|
case strings.Contains(" "+name, " "+q):
|
||||||
|
return 2
|
||||||
|
case strings.Contains(name, q):
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
return 4 // matched by creator
|
||||||
|
}
|
||||||
|
oldest := field == "id" && direction == "asc"
|
||||||
|
sort.SliceStable(list, func(i, j int) bool {
|
||||||
|
if ri, rj := rank(list[i]), rank(list[j]); ri != rj {
|
||||||
|
return ri < rj
|
||||||
|
}
|
||||||
|
if oldest {
|
||||||
|
return list[i].CreatedAt.Before(list[j].CreatedAt)
|
||||||
|
}
|
||||||
|
return list[i].CreatedAt.After(list[j].CreatedAt)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// categoryIDs reads the shop's category field: IDs separated by commas, as a string or number (empty: all).
|
||||||
|
func categoryIDs(raw json.RawMessage) map[int64]bool {
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(raw, &s) != nil {
|
||||||
|
var n json.Number
|
||||||
|
if json.Unmarshal(raw, &n) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s = n.String()
|
||||||
|
}
|
||||||
|
ids := map[int64]bool{}
|
||||||
|
for _, f := range strings.Split(s, ",") {
|
||||||
|
if id, ok := parseID(f); ok {
|
||||||
|
ids[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// inCategories keeps the products under any of the categories (all of them if there are none).
|
||||||
|
func inCategories(list []hub.Product, ids map[int64]bool) []hub.Product {
|
||||||
|
if ids == nil {
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
var out []hub.Product
|
||||||
|
for _, hp := range list {
|
||||||
|
for _, c := range hp.Categories {
|
||||||
|
if ids[c.ID] {
|
||||||
|
out = append(out, hp)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchForShop gets the listed products that aren't here (or are an older revision), a few at a time, so the shop
|
||||||
|
// can show them and they can be tried on.
|
||||||
|
func (p *Products) fetchForShop(ctx context.Context, list []hub.Product) {
|
||||||
|
if p.Fetch == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, shopFetchTime)
|
||||||
|
defer cancel()
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
slots := make(chan struct{}, 6)
|
||||||
|
for _, hp := range list {
|
||||||
|
if s := p.byProductID(hp.ID); s != nil && s.pack.Manifest.Revision >= hp.Latest {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id string) {
|
||||||
|
defer wg.Done()
|
||||||
|
select {
|
||||||
|
case slots <- struct{}{}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { <-slots }()
|
||||||
|
if err := p.Fetch(ctx, id); err != nil {
|
||||||
|
p.logf("shop: %s: %v", id, err)
|
||||||
|
}
|
||||||
|
}(hp.ID)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
p.refresh(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func number(n json.Number, fallback int) int {
|
||||||
|
if v, err := n.Int64(); err == nil {
|
||||||
|
return int(v)
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// interceptPurchase buys T5 products by adding them to the person's library. Products from IMVU in the same
|
||||||
|
// purchase go on to IMVU without the T5 ones.
|
||||||
|
func (p *Products) interceptPurchase(x *proxy.Exchange) error {
|
||||||
|
var body map[string]json.RawMessage
|
||||||
|
if json.Unmarshal(x.Body, &body) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var pids []json.RawMessage
|
||||||
|
if json.Unmarshal(body["products"], &pids) != nil {
|
||||||
|
var one json.RawMessage
|
||||||
|
if json.Unmarshal(body["products"], &one) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pids = []json.RawMessage{one}
|
||||||
|
}
|
||||||
|
var keep []json.RawMessage
|
||||||
|
var t5 []string
|
||||||
|
community := 0
|
||||||
|
for _, raw := range pids {
|
||||||
|
id, ok := rawID(raw)
|
||||||
|
if !ok || !IsCommunity(id) {
|
||||||
|
keep = append(keep, raw)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
community++
|
||||||
|
if pid, ok := p.state.productID(id); ok && p.HubDir != "" && p.inHubDir(pid) {
|
||||||
|
t5 = append(t5, pid)
|
||||||
|
} // local packs are always in the inventory: nothing to buy
|
||||||
|
}
|
||||||
|
if community == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// IMVU's part first (re-signed), so nothing is added to the library for a purchase that can't go on.
|
||||||
|
if len(keep) > 0 {
|
||||||
|
body["products"], _ = json.Marshal(keep)
|
||||||
|
nb, _ := json.Marshal(body)
|
||||||
|
if !p.keys.resign(x.Request.Header, x.Body, x.URL.RawQuery, nb, x.URL.RawQuery) {
|
||||||
|
return errNoKey
|
||||||
|
}
|
||||||
|
x.Body = nb
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
for _, pid := range t5 {
|
||||||
|
if p.AddToLibrary == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := p.AddToLibrary(ctx, pid); err != nil {
|
||||||
|
p.logf("shop: adding %s to the library: %v", pid, err)
|
||||||
|
x.Response = localResponse(200, "application/json", []byte(`{"status":3}`))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(t5) > 0 {
|
||||||
|
p.logf("shop: added %d T5 products to the library", len(t5))
|
||||||
|
}
|
||||||
|
if len(keep) == 0 {
|
||||||
|
// No credits change hands: the client's patch leaves the balance alone when the answer has none.
|
||||||
|
x.Response = localResponse(200, "application/json", []byte(`{"status":1}`))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// inHubDir reports whether a product is served from the hub's products (not a local pack).
|
||||||
|
func (p *Products) inHubDir(id string) bool {
|
||||||
|
s := p.byProductID(id)
|
||||||
|
return s != nil && filepath.Dir(s.dir) == filepath.Clean(p.HubDir)
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package products
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShop(t *testing.T) {
|
||||||
|
h, _ := newHarness(t)
|
||||||
|
hubDir := t.TempDir()
|
||||||
|
h.p.dirs = append(h.p.dirs, hubDir)
|
||||||
|
h.p.HubDir = hubDir
|
||||||
|
library := map[string]bool{"coatcoat22": true}
|
||||||
|
h.p.Owned = func(id string) bool { return library[id] }
|
||||||
|
coat := []hub.Category{{ID: 106, Name: "Clothing"}, {ID: 40, Name: "Clothing for Female"}, {ID: 716, Name: "Coats & <Jackets>"}}
|
||||||
|
hair := []hub.Category{{ID: 3114, Name: "Body"}, {ID: 3115, Name: "Body for Female"}, {ID: 75, Name: "Hair"}}
|
||||||
|
listed := []hub.Product{
|
||||||
|
{ID: "coatcoat22", Name: "Green Coat", Creator: "erin", Rating: "general", Latest: 1, Image: "https://userimages-akm.imvu.com/a.gif", Categories: coat},
|
||||||
|
{ID: "hairhair22", Name: "Red Hair", Creator: "erin", Rating: "adult", Latest: 2, Categories: hair},
|
||||||
|
{ID: "slowslow22", Name: "Never Arrives", Creator: "erin", Rating: "general", Latest: 1, Categories: coat},
|
||||||
|
}
|
||||||
|
var queries []string
|
||||||
|
h.p.Shop = func(ctx context.Context, q string) ([]hub.Product, error) {
|
||||||
|
queries = append(queries, q)
|
||||||
|
return listed, nil
|
||||||
|
}
|
||||||
|
fetched := map[string]int{}
|
||||||
|
var mu sync.Mutex
|
||||||
|
h.p.Fetch = func(ctx context.Context, id string) error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
fetched[id]++
|
||||||
|
if id == "slowslow22" {
|
||||||
|
return fmt.Errorf("no catalog hosts it")
|
||||||
|
}
|
||||||
|
for _, hp := range listed {
|
||||||
|
if hp.ID == id {
|
||||||
|
writePack(t, hubDir, id, hp.Latest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var added []string
|
||||||
|
h.p.AddToLibrary = func(ctx context.Context, id string) error {
|
||||||
|
added = append(added, id)
|
||||||
|
library[id] = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
search := func(rating, category string, page int) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
body, _ := json.Marshal(map[string]any{"category": category, "search": "coat", "skip": (page - 1) * 1, "maturity_rating": rating,
|
||||||
|
"results_per_page": 1, "solr_limit": 2, "page": page})
|
||||||
|
status, b := h.do("POST", "http://client-dynamic.imvu.com/api/shop/search_st.php", body, true, false)
|
||||||
|
var out map[string]any
|
||||||
|
if status != 200 || json.Unmarshal(b, &out) != nil {
|
||||||
|
t.Fatalf("search: %d %s", status, b)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other ratings go to IMVU.
|
||||||
|
search("all", "", 1)
|
||||||
|
if len(h.imvu.asked["search"]) != 1 || len(queries) != 0 {
|
||||||
|
t.Fatal("an IMVU search wasn't left to IMVU")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The T5 filter: the hub's products, fetched so they can be shown and tried on, in IMVU's columns.
|
||||||
|
out := search(ShopRating, "", 1)
|
||||||
|
if queries[0] != "coat" || fetched["coatcoat22"] != 1 || fetched["hairhair22"] != 1 {
|
||||||
|
t.Fatalf("queries %v, fetched %v", queries, fetched)
|
||||||
|
}
|
||||||
|
if out["totalProductCount"].(float64) != 2 || out["pageCount"].(float64) != 2 || out["endIndex"].(float64) != 1 {
|
||||||
|
t.Fatalf("answer %v", out)
|
||||||
|
}
|
||||||
|
result := out["result"].(map[string]any)
|
||||||
|
first, second := result["1"].([]any)[0].([]any), result["2"].([]any)[0].([]any)
|
||||||
|
row := map[string]any{}
|
||||||
|
for i, k := range out["keys"].([]any) {
|
||||||
|
row[k.(string)] = first[i]
|
||||||
|
}
|
||||||
|
coatLocal := h.p.byProductID("coatcoat22").local
|
||||||
|
if row["id"] != fmt.Sprint(coatLocal) || row["name"] != "Green Coat" || row["owns_product"] != true || row["ap"].(float64) != 0 ||
|
||||||
|
row["category"].(float64) != 716 || row["category_name"] != "Coats & Jackets" || row["image"] != "https://userimages-akm.imvu.com/a.gif" {
|
||||||
|
t.Fatalf("row %v", row)
|
||||||
|
}
|
||||||
|
if second[5].(float64) != 1 || second[11] != false {
|
||||||
|
t.Fatalf("the adult product, not owned: %v", second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Products here at the listed revision aren't fetched again; categories filter.
|
||||||
|
out = search(ShopRating, "3115,75", 1)
|
||||||
|
if fetched["coatcoat22"] != 1 || fetched["hairhair22"] != 1 || out["totalProductCount"].(float64) != 1 {
|
||||||
|
t.Fatalf("fetched %v, answer %v", fetched, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before signing in, a mixed purchase can't be re-signed: it fails without touching the library.
|
||||||
|
hairLocal := h.p.byProductID("hairhair22").local
|
||||||
|
purchase := func(pids ...any) string {
|
||||||
|
body, _ := json.Marshal(map[string]any{"products": pids, "context": "sitc", "source": "client"})
|
||||||
|
_, b := h.do("POST", "http://client-dynamic.imvu.com/api/service/shop/svc_catalog_purchase.php", body, true, false)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
if purchase(hairLocal, 12345); len(added) != 0 {
|
||||||
|
t.Fatal("added to the library for a failed purchase")
|
||||||
|
}
|
||||||
|
h.do("POST", "https://secure.imvu.com/api/xmlrpc/client.php", call("test.avatarInfoForLogin2"), false, false)
|
||||||
|
|
||||||
|
// Trying one on asks IMVU's REST API for related products: answered here, with none.
|
||||||
|
status, b := h.do("GET", fmt.Sprintf("https://api.imvu.com/product/product-%d/uml_products?limit=6", coatLocal), nil, false, false)
|
||||||
|
if status != 200 || !strings.Contains(string(b), `"total_count":0`) {
|
||||||
|
t.Fatalf("uml_products: %d %s", status, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buying a T5 product adds it to the library; IMVU hears only of its own products.
|
||||||
|
// Product details: community ones answered here, IMVU asked only for its own.
|
||||||
|
info := func(pids ...any) []map[string]any {
|
||||||
|
body, _ := json.Marshal(map[string]any{"pids": pids})
|
||||||
|
_, b := h.do("POST", "http://client-dynamic.imvu.com/api/shop/product.php", body, true, false)
|
||||||
|
var out struct{ Products []map[string]any }
|
||||||
|
if json.Unmarshal(b, &out) != nil {
|
||||||
|
t.Fatalf("product info: %s", b)
|
||||||
|
}
|
||||||
|
return out.Products
|
||||||
|
}
|
||||||
|
if got := info(coatLocal); len(got) != 1 || got[0]["name"] != "Green Coat" || got[0]["owns_product"] != true || len(h.imvu.asked["product info"]) != 0 {
|
||||||
|
t.Fatalf("product info %v", got)
|
||||||
|
}
|
||||||
|
// The client sends some IDs as floats ("2000000008.0"): those are community products too.
|
||||||
|
body, _ := json.Marshal(map[string]any{"pids": []json.RawMessage{json.RawMessage(fmt.Sprintf("%d.0", coatLocal)), json.RawMessage("12345")}})
|
||||||
|
h.do("POST", "http://client-dynamic.imvu.com/api/shop/product.php", body, true, false)
|
||||||
|
if asked := h.imvu.asked["product info"]; len(asked) != 1 || strings.Contains(asked[0], fmt.Sprint(coatLocal)) {
|
||||||
|
t.Fatalf("a float community ID went to IMVU: %v", asked)
|
||||||
|
}
|
||||||
|
if got := info(fmt.Sprint(hairLocal), 12345); len(got) != 2 || got[0]["name"] != "IMVU's" || got[1]["ap"].(float64) != 1 ||
|
||||||
|
strings.Contains(h.imvu.asked["product info"][1], fmt.Sprint(hairLocal)) {
|
||||||
|
t.Fatalf("mixed product info %v, IMVU got %v", got, h.imvu.asked["product info"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := purchase(fmt.Sprint(hairLocal)); got != `{"status":1}` || len(added) != 1 || added[0] != "hairhair22" || len(h.imvu.asked["purchase"]) != 0 {
|
||||||
|
t.Fatalf("T5 purchase: %s, added %v", got, added)
|
||||||
|
}
|
||||||
|
if got := purchase(hairLocal, 12345); !strings.Contains(got, "balance_credits") || len(h.imvu.asked["purchase"]) != 1 ||
|
||||||
|
strings.Contains(h.imvu.asked["purchase"][0], fmt.Sprint(hairLocal)) || !strings.Contains(h.imvu.asked["purchase"][0], "12345") {
|
||||||
|
t.Fatalf("mixed purchase: %s, IMVU got %v", got, h.imvu.asked["purchase"])
|
||||||
|
}
|
||||||
|
purchase(12345)
|
||||||
|
if len(h.imvu.asked["purchase"]) != 2 {
|
||||||
|
t.Fatal("an IMVU purchase wasn't left to IMVU")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,187 @@
|
|||||||
|
package products
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"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
|
||||||
|
Outfits map[string][]string `json:"outfits,omitempty"` // saved outfit ID -> its community products
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// outfit is the community products saved with an outfit (by IMVU's outfit ID).
|
||||||
|
func (s *state) outfit(id string) []string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return append([]string(nil), s.data.Outfits[id]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setOutfit remembers the community products saved with an outfit.
|
||||||
|
func (s *state) setOutfit(id string, products []string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.data.Outfits == nil {
|
||||||
|
s.data.Outfits = map[string][]string{}
|
||||||
|
}
|
||||||
|
s.data.Outfits[id] = append([]string(nil), products...)
|
||||||
|
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 key, l := range s.data.IDs {
|
||||||
|
if l == local {
|
||||||
|
id, _, _ := strings.Cut(key, "@") // keys are "<product>@<version>" (versionKey)
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
+199
-16
@@ -65,8 +65,18 @@ func (s *Server) serveConnect(w http.ResponseWriter, r *http.Request) {
|
|||||||
client = io.MultiReader(bytes.NewReader(bytes.Clone(head)), conn)
|
client = io.MultiReader(bytes.NewReader(bytes.Clone(head)), conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
up := &stream{s: s, id: id, name: "c2g"}
|
// Frames go to the client whole, from the gateway or from the proxy answering a dropped message, so they
|
||||||
|
// never interleave.
|
||||||
|
toClient := &lockedWriter{w: conn}
|
||||||
down := &stream{s: s, id: id, name: "g2c"}
|
down := &stream{s: s, id: id, name: "g2c"}
|
||||||
|
up := &stream{s: s, id: id, name: "c2g", filter: s.IMQFilter, reply: toClient}
|
||||||
|
if w, ok := s.IMQFilter.(IMQWatcher); ok {
|
||||||
|
c := &IMQConn{ID: id, w: toClient}
|
||||||
|
down.watcher, down.conn = w, c
|
||||||
|
up.watcher, up.conn = w, c
|
||||||
|
w.Opened(c)
|
||||||
|
defer w.Closed(c)
|
||||||
|
}
|
||||||
// IMQ is a session: when either side closes, it's over, so both connections close.
|
// IMQ is a session: when either side closes, it's over, so both connections close.
|
||||||
var once sync.Once
|
var once sync.Once
|
||||||
closeBoth := func() {
|
closeBoth := func() {
|
||||||
@@ -85,13 +95,76 @@ func (s *Server) serveConnect(w http.ResponseWriter, r *http.Request) {
|
|||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
defer closeBoth()
|
defer closeBoth()
|
||||||
down.copy(conn, upstream)
|
down.copy(toClient, upstream)
|
||||||
}()
|
}()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
s.logf("#%d CONNECT %s closed after %s: %s, %s", id, target, since(start), up.summary(), down.summary())
|
s.logf("#%d CONNECT %s closed after %s: %s, %s", id, target, since(start), up.summary(), down.summary())
|
||||||
}
|
}
|
||||||
|
|
||||||
// stream is one direction of a CONNECT, counting bytes and IMQ frames as they pass.
|
// IMQFilter sees the client's outgoing IMQ messages and can change or drop them.
|
||||||
|
type IMQFilter interface {
|
||||||
|
// Outgoing returns the message to send instead of message, or drop to send nothing and tell the client it
|
||||||
|
// was sent.
|
||||||
|
Outgoing(queue, mount string, message []byte) (out []byte, drop bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMQWatcher is an IMQFilter that also follows each IMQ connection: the queues the client subscribes to, the
|
||||||
|
// messages the gateway delivers (which it can change), and it can deliver messages of its own.
|
||||||
|
type IMQWatcher interface {
|
||||||
|
IMQFilter
|
||||||
|
// Opened: the client opened an IMQ connection (its user queue comes with it, without a subscription).
|
||||||
|
Opened(c *IMQConn)
|
||||||
|
// Subscribed and Unsubscribed see the client's subscriptions on a connection.
|
||||||
|
Subscribed(c *IMQConn, queues []string)
|
||||||
|
Unsubscribed(c *IMQConn, queues []string)
|
||||||
|
// Incoming returns the message to deliver instead of m's (m.Message itself to leave it).
|
||||||
|
Incoming(c *IMQConn, m imq.Delivered) []byte
|
||||||
|
// Closed: the connection is over (and with it, its subscriptions).
|
||||||
|
Closed(c *IMQConn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMQRouter is an IMQWatcher that serves some queues itself (T5DE-hosted rooms' queues): it takes them out of the
|
||||||
|
// client's subscriptions before they go to the gateway, and answers the client for them.
|
||||||
|
type IMQRouter interface {
|
||||||
|
// Route returns the part of a subscription (or unsubscription) for the gateway.
|
||||||
|
Route(c *IMQConn, unsubscribe bool, s imq.Subscription) imq.Subscription
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMQConn is one IMQ connection of the client's.
|
||||||
|
type IMQConn struct {
|
||||||
|
ID int64
|
||||||
|
w io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIMQConn is a connection writing frames to w (for tests of watchers).
|
||||||
|
func NewIMQConn(id int64, w io.Writer) *IMQConn { return &IMQConn{ID: id, w: w} }
|
||||||
|
|
||||||
|
// Send gives the client a frame of the given type as if the gateway had sent it.
|
||||||
|
func (c *IMQConn) Send(frameType uint64, data []byte) error {
|
||||||
|
_, err := c.w.Write(imq.EncodeFrame(frameType, data))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver gives the client a message as if the gateway had delivered it.
|
||||||
|
func (c *IMQConn) Deliver(m imq.Delivered) error {
|
||||||
|
_, err := c.w.Write(imq.EncodeFrame(imq.G2cSendMessage, m.Marshal()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// lockedWriter serializes writes, each a whole number of frames.
|
||||||
|
type lockedWriter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
w io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *lockedWriter) Write(b []byte) (int, error) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
return l.w.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stream is one direction of a CONNECT. It passes IMQ frames on whole, counting them (and, outgoing, filtering
|
||||||
|
// them), or bytes as they come once the stream turns out not to be IMQ.
|
||||||
type stream struct {
|
type stream struct {
|
||||||
s *Server
|
s *Server
|
||||||
id int64
|
id int64
|
||||||
@@ -99,7 +172,11 @@ type stream struct {
|
|||||||
bytes int64
|
bytes int64
|
||||||
frames int
|
frames int
|
||||||
other bool // not IMQ framing
|
other bool // not IMQ framing
|
||||||
reader imq.Reader
|
pending []byte
|
||||||
|
filter IMQFilter // outgoing only
|
||||||
|
reply io.Writer // outgoing only: to the client
|
||||||
|
watcher IMQWatcher
|
||||||
|
conn *IMQConn
|
||||||
}
|
}
|
||||||
|
|
||||||
func (st *stream) copy(dst io.Writer, src io.Reader) {
|
func (st *stream) copy(dst io.Writer, src io.Reader) {
|
||||||
@@ -107,8 +184,8 @@ func (st *stream) copy(dst io.Writer, src io.Reader) {
|
|||||||
for {
|
for {
|
||||||
n, err := src.Read(buf)
|
n, err := src.Read(buf)
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
st.observe(buf[:n])
|
st.bytes += int64(n)
|
||||||
if _, werr := dst.Write(buf[:n]); werr != nil {
|
if werr := st.pass(dst, buf[:n]); werr != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,24 +195,130 @@ func (st *stream) copy(dst io.Writer, src io.Reader) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (st *stream) observe(data []byte) {
|
// pass writes on the frames data completes.
|
||||||
st.bytes += int64(len(data))
|
func (st *stream) pass(dst io.Writer, data []byte) error {
|
||||||
if st.other {
|
if st.other {
|
||||||
return
|
_, err := dst.Write(data)
|
||||||
}
|
return err
|
||||||
frames, err := st.reader.Write(data)
|
|
||||||
st.frames += len(frames)
|
|
||||||
if st.s.Trace {
|
|
||||||
for _, f := range frames {
|
|
||||||
st.s.logf("#%d %s %s %dB", st.id, st.name, imq.TypeName(f.Type), f.Size)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
st.pending = append(st.pending, data...)
|
||||||
|
var out []byte
|
||||||
|
for {
|
||||||
|
f, n, err := imq.Next(st.pending)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
st.other = true
|
st.other = true
|
||||||
st.s.logf("#%d %s isn't IMQ framing; passing it through", st.id, st.name)
|
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.watcher != nil {
|
||||||
|
switch f.Type {
|
||||||
|
case imq.C2gSubscribe, imq.C2gUnsubscribe:
|
||||||
|
sub, err := imq.ParseSubscriptionOps(f.Data)
|
||||||
|
if err != nil {
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
if f.Type == imq.C2gSubscribe {
|
||||||
|
st.watcher.Subscribed(st.conn, sub.All())
|
||||||
|
} else {
|
||||||
|
st.watcher.Unsubscribed(st.conn, sub.All())
|
||||||
|
}
|
||||||
|
router, ok := st.watcher.(IMQRouter)
|
||||||
|
if !ok {
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
rest := router.Route(st.conn, f.Type == imq.C2gUnsubscribe, sub)
|
||||||
|
if rest.Empty() {
|
||||||
|
return keepalive()
|
||||||
|
}
|
||||||
|
if len(rest.All()) == len(sub.All()) {
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
return imq.EncodeFrame(f.Type, rest.Marshal())
|
||||||
|
case imq.C2gOpenFloodgates:
|
||||||
|
// Resuming queues after a reconnect: the router's are answered here, the rest go to the gateway.
|
||||||
|
router, ok := st.watcher.(IMQRouter)
|
||||||
|
if !ok {
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
states, err := imq.ParseFloodgates(f.Data)
|
||||||
|
if err != nil {
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
var rest []imq.QueueOp
|
||||||
|
for _, q := range states {
|
||||||
|
if router.Route(st.conn, false, imq.Subscription{WithResults: []imq.QueueOp{q}}).Empty() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rest = append(rest, q)
|
||||||
|
}
|
||||||
|
if len(rest) == len(states) {
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
return imq.EncodeFrame(imq.C2gOpenFloodgates, imq.MarshalFloodgates(rest))
|
||||||
|
case imq.G2cSendMessage:
|
||||||
|
m, err := imq.ParseDelivered(f.Data)
|
||||||
|
if err != nil {
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
out := st.watcher.Incoming(st.conn, m)
|
||||||
|
if bytes.Equal(out, m.Message) {
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
m.Message = out
|
||||||
|
return imq.EncodeFrame(imq.G2cSendMessage, m.Marshal())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if st.filter == nil || f.Type != imq.C2gSendMessage {
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
m, err := imq.ParseSendMessage(f.Data)
|
||||||
|
if err != nil {
|
||||||
|
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 keepalive()
|
||||||
|
}
|
||||||
|
if bytes.Equal(out, m.Message) {
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
m.Message = out
|
||||||
|
return imq.EncodeFrame(imq.C2gSendMessage, m.Marshal())
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepalive is what goes to the gateway in place of a frame the proxy answered itself: a ping. The client doesn't
|
||||||
|
// ping while it's sending, so without it the gateway sees an idle connection and closes it (in a hosted room, where
|
||||||
|
// everything the client sends is answered here).
|
||||||
|
func keepalive() []byte { return imq.EncodeFrame(imq.C2gPing, nil) }
|
||||||
|
|
||||||
func (st *stream) summary() string {
|
func (st *stream) summary() string {
|
||||||
if st.other {
|
if st.other {
|
||||||
return fmt.Sprintf("%s %dB", st.name, st.bytes)
|
return fmt.Sprintf("%s %dB", st.name, st.bytes)
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dump logs the request and response bodies of requests whose path contains Match (T5DE_PROXY_DUMP, for finding
|
||||||
|
// out what IMVU's APIs send). It may log personal data: only for test accounts.
|
||||||
|
type Dump struct {
|
||||||
|
Match string
|
||||||
|
Log *log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Dump) Intercept(x *Exchange) error {
|
||||||
|
if d.Match == "" || !strings.Contains(x.URL.Path, d.Match) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
d.Log.Printf("dump: %s %s\n%s", x.Request.Method, x.URL, x.Body)
|
||||||
|
next := x.ModifyResponse
|
||||||
|
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
|
||||||
|
if next != nil {
|
||||||
|
var err error
|
||||||
|
if body, err = next(resp, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.Log.Printf("dump: %s answered %d\n%s", x.URL.Path, resp.StatusCode, body)
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ package proxy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
"context"
|
"context"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
@@ -24,6 +25,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@@ -45,6 +47,10 @@ type Server struct {
|
|||||||
// TLSConfig is the base TLS configuration of CONNECT's upstream connections (tests set RootCAs); nil is the
|
// TLSConfig is the base TLS configuration of CONNECT's upstream connections (tests set RootCAs); nil is the
|
||||||
// default, with the system's roots.
|
// default, with the system's roots.
|
||||||
TLSConfig *tls.Config
|
TLSConfig *tls.Config
|
||||||
|
// Interceptor, if set, sees every HTTP request (with a body the proxy could hold in memory).
|
||||||
|
Interceptor Interceptor
|
||||||
|
// IMQFilter, if set, sees the client's outgoing IMQ messages (on CONNECT streams) and can change or drop them.
|
||||||
|
IMQFilter IMQFilter
|
||||||
// Dial makes CONNECT's upstream connections (tests point it at local servers); nil is a net.Dialer.
|
// Dial makes CONNECT's upstream connections (tests point it at local servers); nil is a net.Dialer.
|
||||||
Dial func(ctx context.Context, network, addr string) (net.Conn, error)
|
Dial func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||||
|
|
||||||
@@ -169,6 +175,29 @@ func (s *Server) forward(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "t5de-proxy: "+err.Error(), http.StatusBadRequest)
|
http.Error(w, "t5de-proxy: "+err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
var modify func(*http.Response, []byte) ([]byte, error)
|
||||||
|
if s.Interceptor != nil && replayable {
|
||||||
|
x := &Exchange{URL: r.URL, Request: out}
|
||||||
|
if out.GetBody != nil {
|
||||||
|
body, _ := out.GetBody()
|
||||||
|
x.Body, _ = io.ReadAll(body)
|
||||||
|
}
|
||||||
|
original := x.Body
|
||||||
|
if err := s.Interceptor.Intercept(x); err != nil {
|
||||||
|
s.logf("#%d %s %s: %v", id, r.Method, s.logURL(r.URL), err)
|
||||||
|
w.Header().Set(ErrorHeader, err.Error())
|
||||||
|
http.Error(w, "t5de-proxy: "+err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if x.Response != nil {
|
||||||
|
s.writeLocal(w, id, r, x.Response, start)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !bytes.Equal(x.Body, original) {
|
||||||
|
setBody(out, x.Body)
|
||||||
|
}
|
||||||
|
modify = x.ModifyResponse
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := s.transport().RoundTrip(out)
|
resp, err := s.transport().RoundTrip(out)
|
||||||
if err != nil && upgraded && replayable && r.Context().Err() == nil {
|
if err != nil && upgraded && replayable && r.Context().Err() == nil {
|
||||||
@@ -191,6 +220,11 @@ func (s *Server) forward(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
if modify != nil {
|
||||||
|
if err := modifyResponse(resp, modify); err != nil {
|
||||||
|
s.logf("#%d %s %s: changing the response: %v", id, r.Method, s.logURL(r.URL), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
removeHopHeaders(resp.Header)
|
removeHopHeaders(resp.Header)
|
||||||
for name, values := range resp.Header {
|
for name, values := range resp.Header {
|
||||||
@@ -208,6 +242,85 @@ func (s *Server) forward(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.logf("#%d %s %s %d %dB %s%s", id, r.Method, s.logURL(r.URL), resp.StatusCode, n, since(start), note)
|
s.logf("#%d %s %s %d %dB %s%s", id, r.Method, s.logURL(r.URL), resp.StatusCode, n, since(start), note)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Interceptor can answer requests itself, change them before they're sent, and change their responses. The
|
||||||
|
// t5de-proxy's community products are served this way.
|
||||||
|
type Interceptor interface {
|
||||||
|
Intercept(x *Exchange) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange is a request on its way, as an Interceptor sees it.
|
||||||
|
type Exchange struct {
|
||||||
|
// URL is the URL the client asked for (before any upgrade to HTTPS).
|
||||||
|
URL *url.URL
|
||||||
|
// Request is the request about to be sent; its body is Body.
|
||||||
|
Request *http.Request
|
||||||
|
// Body is the request's body; an Interceptor may replace it.
|
||||||
|
Body []byte
|
||||||
|
// Response, if an Interceptor sets it, answers the request: nothing is sent.
|
||||||
|
Response *http.Response
|
||||||
|
// ModifyResponse, if set, changes the server's response body (decompressed) before the client gets it.
|
||||||
|
ModifyResponse func(resp *http.Response, body []byte) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalResponse is a response made by the proxy.
|
||||||
|
func LocalResponse(status int, contentType string, body []byte) *http.Response {
|
||||||
|
h := http.Header{}
|
||||||
|
h.Set("Content-Type", contentType)
|
||||||
|
h.Set("Content-Length", strconv.Itoa(len(body)))
|
||||||
|
return &http.Response{StatusCode: status, Header: h, ContentLength: int64(len(body)), Body: io.NopCloser(bytes.NewReader(body))}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) writeLocal(w http.ResponseWriter, id int64, r *http.Request, resp *http.Response, start time.Time) {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
for name, values := range resp.Header {
|
||||||
|
w.Header()[name] = values
|
||||||
|
}
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
n, _ := io.Copy(w, resp.Body)
|
||||||
|
s.logf("#%d %s %s %d %dB %s (answered by the proxy)", id, r.Method, s.logURL(r.URL), resp.StatusCode, n, since(start))
|
||||||
|
}
|
||||||
|
|
||||||
|
func setBody(out *http.Request, body []byte) {
|
||||||
|
out.ContentLength = int64(len(body))
|
||||||
|
out.Header.Set("Content-Length", strconv.Itoa(len(body)))
|
||||||
|
out.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil }
|
||||||
|
out.Body, _ = out.GetBody()
|
||||||
|
if len(body) == 0 {
|
||||||
|
out.Body, out.GetBody = nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// modifyResponse replaces resp's body with modify's version of it, uncompressed.
|
||||||
|
func modifyResponse(resp *http.Response, modify func(*http.Response, []byte) ([]byte, error)) error {
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBuffered))
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
resp.Body = io.NopCloser(bytes.NewReader(raw))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := raw
|
||||||
|
if strings.EqualFold(resp.Header.Get("Content-Encoding"), "gzip") {
|
||||||
|
zr, err := gzip.NewReader(bytes.NewReader(raw))
|
||||||
|
if err == nil {
|
||||||
|
body, err = io.ReadAll(io.LimitReader(zr, maxBuffered))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
resp.Body = io.NopCloser(bytes.NewReader(raw))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed, err := modify(resp, body)
|
||||||
|
if err != nil {
|
||||||
|
changed = body
|
||||||
|
}
|
||||||
|
resp.Header.Del("Content-Encoding")
|
||||||
|
resp.Header.Del("Content-Md5")
|
||||||
|
resp.Header.Set("Content-Length", strconv.Itoa(len(changed)))
|
||||||
|
resp.ContentLength = int64(len(changed))
|
||||||
|
resp.Body = io.NopCloser(bytes.NewReader(changed))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// maxBuffered bounds the request bodies the proxy holds in memory; larger ones (uploads) are streamed.
|
// maxBuffered bounds the request bodies the proxy holds in memory; larger ones (uploads) are streamed.
|
||||||
const maxBuffered = 32 << 20
|
const maxBuffered = 32 << 20
|
||||||
|
|
||||||
@@ -261,3 +374,18 @@ func copyFlushing(w http.ResponseWriter, body io.Reader) (int64, error) {
|
|||||||
func since(t time.Time) string {
|
func since(t time.Time) string {
|
||||||
return time.Since(t).Round(time.Millisecond).String()
|
return time.Since(t).Round(time.Millisecond).String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Interceptors runs several Interceptors in order, stopping at the first that answers the request itself.
|
||||||
|
type Interceptors []Interceptor
|
||||||
|
|
||||||
|
func (is Interceptors) Intercept(x *Exchange) error {
|
||||||
|
for _, i := range is {
|
||||||
|
if err := i.Intercept(x); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if x.Response != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package proxy
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
@@ -18,6 +19,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/imq"
|
||||||
)
|
)
|
||||||
|
|
||||||
const token = "secret"
|
const token = "secret"
|
||||||
@@ -340,3 +343,235 @@ func TestFallsBackToHTTPWithTheBody(t *testing.T) {
|
|||||||
t.Fatalf("%d %q", resp.StatusCode, got)
|
t.Fatalf("%d %q", resp.StatusCode, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// suffixFilter appends "!" to messages on mount "messages" and drops those saying "drop".
|
||||||
|
type suffixFilter struct{}
|
||||||
|
|
||||||
|
func (suffixFilter) Outgoing(queue, mount string, message []byte) ([]byte, bool) {
|
||||||
|
if string(message) == "drop" {
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
if mount == "messages" {
|
||||||
|
return append(bytes.Clone(message), '!'), false
|
||||||
|
}
|
||||||
|
return message, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectFiltersOutgoingMessages(t *testing.T) {
|
||||||
|
gateway := httptest.NewUnstartedServer(nil)
|
||||||
|
gateway.StartTLS()
|
||||||
|
gateway.Close()
|
||||||
|
ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: gateway.TLS.Certificates})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
received := make(chan []byte, 1)
|
||||||
|
go func() {
|
||||||
|
c, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
var got []byte
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
c.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||||
|
for {
|
||||||
|
n, err := c.Read(buf)
|
||||||
|
got = append(got, buf[:n]...)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
received <- got
|
||||||
|
}()
|
||||||
|
roots := gateway.Client().Transport.(*http.Transport).TLSClientConfig.RootCAs
|
||||||
|
p, logs := newProxy(t, &Server{TLSConfig: &tls.Config{RootCAs: roots}, IMQFilter: suffixFilter{},
|
||||||
|
Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return net.Dial(network, ln.Addr().String())
|
||||||
|
}})
|
||||||
|
conn, err := net.Dial("tcp", p.Listener.Addr().String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
send := func(op uint64, mount, msg string) []byte {
|
||||||
|
return imq.EncodeFrame(imq.C2gSendMessage, imq.SendMessage{OpID: op, Queue: "/chat/1", Mount: mount, Message: []byte(msg)}.Marshal())
|
||||||
|
}
|
||||||
|
// Sent in pieces, to check frames are put back together.
|
||||||
|
stream := append(append(send(1, "messages", "hi"), send(2, "messages", "drop")...), send(3, "control", "x")...)
|
||||||
|
fmt.Fprintf(conn, "CONNECT example.com:443 HTTP/1.1\r\nProxy-authorization: %s\r\n\r\n", auth)
|
||||||
|
r := bufio.NewReader(conn)
|
||||||
|
for line, _ := r.ReadString('\n'); line != "\r\n"; line, _ = r.ReadString('\n') {
|
||||||
|
if line == "" {
|
||||||
|
t.Fatalf("no response; log %s", logs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; i < len(stream); i += 5 {
|
||||||
|
conn.Write(stream[i:min(i+5, len(stream))])
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
// The dropped message is answered by the proxy.
|
||||||
|
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||||
|
var fromProxy []byte
|
||||||
|
buf := make([]byte, 64)
|
||||||
|
for len(fromProxy) < len(imq.EncodeFrame(imq.G2cResult, imq.Result(2, 0))) {
|
||||||
|
n, err := r.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v; log %s", err, logs)
|
||||||
|
}
|
||||||
|
fromProxy = append(fromProxy, buf[:n]...)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(fromProxy, imq.EncodeFrame(imq.G2cResult, imq.Result(2, 0))) {
|
||||||
|
t.Fatalf("client got %x", fromProxy)
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
got := <-received
|
||||||
|
// The dropped message's place: a ping, so the gateway doesn't see an idle connection.
|
||||||
|
want := append(append(send(1, "messages", "hi!"), imq.EncodeFrame(imq.C2gPing, nil)...), 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testWatcher records subscriptions, uppercases delivered messages, and delivers "welcome" on each subscription.
|
||||||
|
type testWatcher struct {
|
||||||
|
suffixFilter
|
||||||
|
mu sync.Mutex
|
||||||
|
events []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *testWatcher) add(e string) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
w.events = append(w.events, e)
|
||||||
|
}
|
||||||
|
func (w *testWatcher) Subscribed(c *IMQConn, queues []string) {
|
||||||
|
w.add("sub " + strings.Join(queues, ","))
|
||||||
|
c.Deliver(imq.Delivered{UserID: "9", Queue: queues[0], Mount: "messages", Message: []byte("welcome")})
|
||||||
|
}
|
||||||
|
func (w *testWatcher) Unsubscribed(c *IMQConn, queues []string) {
|
||||||
|
w.add("unsub " + strings.Join(queues, ","))
|
||||||
|
}
|
||||||
|
func (w *testWatcher) Incoming(c *IMQConn, m imq.Delivered) []byte {
|
||||||
|
return bytes.ToUpper(m.Message)
|
||||||
|
}
|
||||||
|
func (w *testWatcher) Closed(c *IMQConn) { w.add("closed") }
|
||||||
|
func (w *testWatcher) Opened(c *IMQConn) {}
|
||||||
|
|
||||||
|
func TestConnectWatchesSubscriptionsAndDeliveries(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()
|
||||||
|
delivered := imq.EncodeFrame(imq.G2cSendMessage, imq.Delivered{UserID: "/user/5", Queue: "/chat/1", Mount: "messages", Message: []byte("hello"), Sequence: 3}.Marshal())
|
||||||
|
go func() {
|
||||||
|
c, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
c.Read(buf) // the subscription
|
||||||
|
c.Write(delivered)
|
||||||
|
c.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||||
|
c.Read(buf)
|
||||||
|
}()
|
||||||
|
roots := gateway.Client().Transport.(*http.Transport).TLSClientConfig.RootCAs
|
||||||
|
w := &testWatcher{}
|
||||||
|
p, logs := newProxy(t, &Server{TLSConfig: &tls.Config{RootCAs: roots}, IMQFilter: w,
|
||||||
|
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()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// queues: field 1; queues_with_results: field 2 (a Subscription, name in field 1).
|
||||||
|
sub := []byte{0x0a, 7}
|
||||||
|
sub = append(sub, "/chat/1"...)
|
||||||
|
sub = append(sub, 0x12, 9, 0x0a, 7)
|
||||||
|
sub = append(sub, "/chat/2"...)
|
||||||
|
conn.Write(imq.EncodeFrame(imq.C2gSubscribe, sub))
|
||||||
|
|
||||||
|
want := append(
|
||||||
|
imq.EncodeFrame(imq.G2cSendMessage, imq.Delivered{UserID: "9", Queue: "/chat/1", Mount: "messages", Message: []byte("welcome")}.Marshal()),
|
||||||
|
imq.EncodeFrame(imq.G2cSendMessage, imq.Delivered{UserID: "/user/5", Queue: "/chat/1", Mount: "messages", Message: []byte("HELLO"), Sequence: 3}.Marshal())...)
|
||||||
|
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||||
|
got := make([]byte, len(want))
|
||||||
|
if _, err := io.ReadFull(r, got); err != nil {
|
||||||
|
t.Fatalf("%v; log %s", err, logs)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got, want) {
|
||||||
|
t.Fatalf("client got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
w.mu.Lock()
|
||||||
|
n := len(w.events)
|
||||||
|
w.mu.Unlock()
|
||||||
|
if n == 2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if strings.Join(w.events, "; ") != "sub /chat/1,/chat/2; closed" {
|
||||||
|
t.Fatalf("events %q", w.events)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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