Compare commits

...
35 Commits
Author SHA1 Message Date
dhkatzandClaude Opus 5.5 8ae45fc07a Choose the hub: Settings' choice, else the install's channel's; state kept per hub
CI / build (push) Successful in 18s
The client's Settings > T5DE chooses the hub from the next start on (POST /hub/server, kept in server.json);
without a choice, -channel (which the client passes) decides: staging for nightly and dev, T5DE's hub for
releases. $T5DE_HUB still wins. Each hub's sign-in, product IDs and products are kept in their own folder
(T5DE's hub in the data folder as before, others in hubs/<host>), so a hub never gets another's token and
product IDs from two hubs never clash; a sign-in saved for another hub is ignored. Signing in always goes to
this run's hub.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:09:34 -08:00
dhkatzandClaude Opus 5.5 8daf91ede3 Manage T5DE rooms from the client; the AP+ badge on avatar cards
CI / build (push) Successful in 28s
Chat Rooms > Manage (the t5de-room-manage patch) lists the person's T5DE rooms
(room_management_info.php?t5de=1) and saves them from IMVU's room dialog (room_management.php: create
with t5de=1, edit and delete naming a hosted room) as hub calls: the room, its moderators by name, and a
picture from the person's IMVU gallery, uploaded to the hub. The hub's refusals show in the dialog
(hub.Error keeps their message).

Avatar cards get show_ap_plus again, which IMVU stopped sending, from the person's public IMVU profile
(is_ap_plus).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:19:33 -07:00
dhkatzandClaude Opus 5.5 6aac769246 Full hosted rooms are refused with IMVU's own full-room code
CI / build (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:07:31 -07:00
dhkatzandClaude Opus 5.5 56c47e806f Remove the hosted rooms' old owner mapping
CI / build (push) Successful in 28s
Hosted rooms' instance IDs start with their owner's IMVU account now, so the client asks about their
products as the owner itself, and the mapping from 2100000000 is never used.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:05:25 -07:00
dhkatzandClaude Opus 5.5 2df46da3ad Invitations into hosted rooms: the dialog's details, and joining as a public room
CI / build (push) Successful in 34s
The invitation carries the room as the dialog shows it (name, owner, image, occupancy, description)
and the inviter's picture, age and gender from IMVU's public profile. Accepting names the room
(room_instance_id), so the client joins it as a public room with the invite rather than opening a
private chat, and getParticipants before joining asks the hub, so the client sees its inviter there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:44:33 -07:00
dhkatzandClaude Opus 5.5 210bc064e9 Hosted rooms phase 2: boots, invitations, favorites, ratings, live occupancy
CI / build (push) Successful in 36s
The client's own room actions work in hosted rooms, through the hub (t5de-hub's docs/hosted-rooms.md,
Phase 2): a "*boot" line goes to the room, then to the hub as room.boot; refused joins are declined as
the client's own are (booted ones as booted). Invitations: chat.attemptInvite polls the hub's answer,
and the invitee's client gets chatInvite on its user queue, accepting with the invite. Favorites
(favorite_rooms.php, add/removeFavoriteRoom, the favorites list), ratings (room_rating.php), the room
card's moderators, image and score, and room_occupancy queues fed from room.watch.

Web API parameters are read from JSON bodies too (imvu.http.securePost), and hosted rows added to an
empty IMVU list keep their flags numeric ("0" counted as set).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:30:42 -07:00
dhkatzandClaude Opus 5.5 fc59367ba6 T5 badge on room cards and the Manage tab; room_info.php for hosted rooms
CI / build (push) Successful in 44s
room_info.php (the room card's participants, asked for every room) is answered for hosted rooms from
the hub (it was refused as naming a hosted room, which the client showed as an error), with max_users;
it and getRoomCardInfo get t5de_t5 for hosted and decorated rooms, as do the person's own rooms in
room_management_info.php.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:09:41 -07:00
dhkatzandClaude Opus 5.5 d379d848c1 Mark rooms needing T5DE in the room list (t5de_t5), for the client's T5 badge
CI / build (push) Successful in 30s
Hosted rooms, and IMVU rooms the hub has decorations for (asked 20 at a time), on every page; hosted
rooms are added to recent rooms from the hub's recent list when it has one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:58:12 -07:00
dhkatzandClaude Opus 5.5 08a2ed94a3 Keep the gateway connection alive and resume hosted queues after reconnecting
CI / build (push) Successful in 30s
The client doesn't ping while it's sending, so when the proxy answers its frames itself (everything it
sends in a hosted room), IMVU's gateway saw an idle connection and closed it after about a minute. A
ping now goes to the gateway in place of each answered frame. And after a reconnect, hosted rooms'
queues are taken out of C2gOpenFloodgates and answered here (the gateway never answered them, so the
client gave up and closed the room).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:37:53 -07:00
dhkatzandClaude Opus 5.5 c483099558 Hosted rooms: seats, owner-first room IDs, T5 items in their scenes
CI / build (push) Successful in 31s
- The seat chosen on joining, and "*msg SeatAssignment" room seats, go to the hub, so people joining get
  free seats (both stood on seat 1).
- Instance IDs are "<owner>-<2100000000+n>" (the hub's change): the client takes the owner from them, so
  owners can decorate their rooms.
- A hosted room's scene carries community products as the hub's IDs (placements too), turned into local
  IDs by each proxy, instead of being stripped; saved and loaded scenes translate placements too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:26:02 -07:00
dhkatzandClaude Opus 5.5 13ee023836 Decorations: keep them through IMVU's own room updates
CI / build (push) Successful in 33s
After the owner saves, IMVU's server sends everyone the saved room ({"command": "SetRoomState"} from
admin on the chat's control mount), which has no decorations: the proxy now adds them to it, and takes
community products out of any outgoing one. A change notice from the hub also no longer empties the
remembered decorations while the new ones are asked for (a broadcast arriving meanwhile went through
without them).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:00:11 -07:00
dhkatzandClaude Opus 5.5 0be2cd8f4e T5 furniture and poses in IMVU rooms (decorations)
CI / build (push) Successful in 44s
Community products are taken out of rooms their owners save and broadcast, kept on the hub per room,
and added back when anyone with T5DE loads the room or gets its owner's broadcast; the hub's decor
notice resends the room with the new ones. Placements are stored with the hub's product ID.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:03:21 -07:00
dhkatzandClaude Opus 5.5 d8332b21a8 Serve T5DE-hosted rooms; a second client instance's own data
CI / build (push) Successful in 36s
Hosted rooms (internal/hosted): room calls for them answered here, their IMQ queues served (a new
proxy.IMQRouter hook takes them out of subscriptions to IMVU), chat relayed through the hub's
WebSocket (hub.Client gains Send, OnRoom and OnConnect), the room list extended, room products
checked against the real owner, and anything else naming a hosted room kept from IMVU.

T5DE_INSTANCE gives a second client's proxy its own data folder and log.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:36:17 -07:00
dhkatzandClaude Opus 5.5 455dae1013 Answer the client's T5 badge lookups
CI / build (push) Successful in 29s
POST http://t5de-proxy.local/badges {imvuIds} -> {badges}: which IMVU accounts show the T5 badge,
asked of the hub (POST /api/v1/badges, in batches of 200) and remembered for 10 minutes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:52:55 -07:00
dhkatzandClaude Opus 5.5 171e8173af Recently added, shop sorting, and outfit cards with T5 products
CI / build (push) Successful in 34s
- The inventory's Recently Added puts T5 products added to the library in the last 30 days first.
- The shop's T5 listing follows its sort menu: Best Match ranks by name, Newest by upload date
  (oldest first ascending); ratings and prices (T5 products have neither) sort by newest.
- Outfit cards: outfit_products.php gets the outfit's community products added (not for sale),
  find_bundle.php is asked about IMVU's products only, and removing one in the card (remove_pids)
  keeps it from IMVU and forgets it in the outfit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:45:02 -07:00
dhkatzandClaude Opus 5.5 af9158513b Answer IMVU's product REST API for community products
CI / build (push) Successful in 27s
Trying a product on in the shop asks api.imvu.com/product/product-<id>/uml_products for related
products; the guard refused it for T5 products, and the client showed an error. Community products
now get an empty list (and not found for anything else) from the proxy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:26:04 -07:00
dhkatzandClaude Opus 5.5 2280ad6314 Show T5 products in the client's shop; keep float IDs and unknown paths off IMVU
CI / build (push) Successful in 34s
The shop's T5 filter (maturity_rating "t5") is answered from the hub's products, fetched so they can be
shown and tried on; buying T5 products adds them to the hub library, and product.php details for
community products are answered here.

The client sends some product IDs as floats ("2000000008.0"): product.php passed the worn community
product to IMVU that way. Every ID is now read either way, and products.Guard refuses any request to
IMVU that still names a local ID given out. T5DE_PROXY_DUMP logs request and response bodies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:15:16 -07:00
dhkatzandClaude Opus 5.5 6506c641f0 Keep community products out of saved outfits
CI / build (push) Successful in 43s
Saving an outfit (POST /api/outfits.php) sent its community products to IMVU. They're now taken out (any ID in
the community range, known or not), the request re-signed, and the products remembered under the outfit's ID,
then added back when the client loads its saved outfits (marked AP if one is adult).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:30:59 -07:00
dhkatzandClaude Opus 5.5 400831bf3c Sign in again to the hub last signed in to
CI / build (push) Successful in 30s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:25:47 -07:00
dhkatzandClaude Opus 5.5 5eca0b57a6 Sign in and out of the hub from the client: the proxy answers t5de-proxy.local
CI / build (push) Successful in 28s
A manager runs the hub connection, so signing in or out takes effect while the client runs; the client's T5DE
settings page asks it for the status and to sign in (showing the code and opening the hub's page) or out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:18:14 -07:00
dhkatzandClaude Opus 5.5 5b4e44c4e7 Refresh the inventory on the user's queue, which the client joins by connecting
CI / build (push) Successful in 25s
The client never subscribes to its user queue; the proxy now follows every IMQ connection and the signed-in
account (from the login answer), and delivers the refresh event on /user/<id>. Checked live: removing a product
from the library on the hub reloads the client's inventory.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:16:30 -07:00
dhkatzandClaude Opus 5.5 ca7a86ce89 T5 products in the client's inventory
CI / build (push) Successful in 25s
The inventory's product list (test.showInventory) gets the community products the person has: local packs, and
hub products in their library. Hiding one is answered by the proxy (and removes a hub product from the library).
When the library changes, the client is told to load its inventory again, with the server event IMVU sends
after a purchase. A product whose revision or info changes gets a new local ID, since the client keeps product
info for good; names lose characters the client's pages could take as HTML.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:59:52 -07:00
dhkatzandClaude Opus 5.5 c281fb572c Get T5 products from the hub's catalogs
CI / build (push) Successful in 25s
The proxy keeps the person's library in a cache folder (hub-products in its data folder), one pack per
product, served like any other pack: the hub says which revision to load and which catalogs host it, each file
comes from a catalog and is checked against the hub's sha256 (files are kept by hash, so a new revision only
downloads what changed), and adult files are fetched with the hub's access token. Products someone wears in a
room that aren't here yet are fetched too, and put on them when they arrive.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:56:26 -07:00
dhkatzandClaude Opus 5.5 93fa2f5d90 Room channel: other T5DE users' community products on their avatars
CI / build (push) Successful in 28s
The proxy follows the client's IMQ chat subscriptions and tells the hub which chats it's in (and the room, from
chat.getOrMakeChat). For the other T5DE users the hub says are in a chat, it gives the client the community
products it has packs for, as *use and *remove lines from them, and adds them to the whole outfits they send.
It also takes community products out of the outfit the client posts to IMVU's chat participant endpoint, which
had been sending them to IMVU.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:14:44 -07:00
dhkatzandClaude Opus 5.5 e394f1dc24 hub: send an empty outfit as a list
CI / build (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:07:30 -07:00
dhkatzandClaude Opus 5.5 bfd25e2b84 Sign in to the hub with its Better Auth device flow; session token as bearer
CI / build (push) Successful in 28s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 04:40:35 -07:00
dhkatzandClaude Opus 5.5 7887eada61 Sign in to T5DE's hub and report presence and community outfits
CI / build (push) Successful in 1m6s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 03:45:55 -07:00
dhkatzandClaude Opus 5.5 50e4db8521 README: tidy the feature list
CI / build (push) Successful in 20s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 03:20:15 -07:00
dhkatzandClaude Opus 5.5 92103098f8 Use the published t5de-api
CI / build (push) Successful in 43s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 03:20:03 -07:00
dhkatzandClaude Opus 5.5 0e5ecf54ae Document community products
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 03:17:30 -07:00
dhkatzandClaude Opus 5.5 887e8a2364 Serve community products from local packs
The proxy serves products from local packs (t5de-api's format) under local IDs from 2,000,000,000: product
info and permission answers (with the parent's derivation chain from IMVU), the product's CFL, and the shop's
bundle lookup. Community products are taken out of outfit saves and outfit chat commands before IMVU sees
them, remembered per account with the outfit they belong to, and added back when the client loads that
outfit. Changed requests are re-signed with the session key from the login response.

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

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

No files matched your search

+3
View File
@@ -12,6 +12,9 @@ on:
jobs:
build:
runs-on: ubuntu-latest
env:
# T5DE's modules (t5de-api) come straight from its Gitea, not through Go's public module proxy.
GOPRIVATE: git.t5de.dev
steps:
- uses: actions/checkout@v4
with:
+8
View File
@@ -24,3 +24,11 @@ in the `t5de` repository (git.t5de.dev/t5de/t5de, usually checked out beside thi
- t5de's `tools/drive.py` starts the built client, takes screenshots, clicks, and sends `imvu:` commands
(`showMode?mode=shop`, ...), so the proxy can be tested in the real client without a person; the test
account's saved login signs in by itself. The log is `%TEMP%\t5de-proxy.log`.
- Community products (README): `internal/products`. What the client needs was worked out from captures
(t5de-hub's `docs/imq-rooms.md` and this repo's README): a product's CFL holds `index.xml` (deriving from a
parent with `__DATAIMPORT product://<id>/index.xml`), materials and textures; `_contents.json` would make the
client fetch files separately, so packs don't use it. Outfit saves happen only from some modes (Dress Up, not
rooms). Test packs (never committed: they're built on IMVU products' templates) live outside the repo; tests use
synthetic files.
- Debugging: `-trace` (or `T5DE_PROXY_TRACE=1`) logs full URLs and IMQ frame types; changes to community products
are logged as `products: ...`.
+76
View File
@@ -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
gateway over TLS.
- **IMVU's reporting endpoints refused**, as T5DE's patches already do in the client.
- **Community products** from local packs (t5de-api's pack format), kept away from IMVU (below).
If the proxy isn't there or stops, the client connects directly, as before.
@@ -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`
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
go test ./...
+217 -3
View File
@@ -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
// (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
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"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/products"
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
)
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")
ready := flag.String("ready", "", "file to write the address and token to once listening")
parentPID := flag.Int("parent", 0, "exit when this process exits")
logFile := flag.String("log", "", "log file (default: standard error)")
trace := flag.Bool("trace", false, "log full URLs and every IMQ frame")
packs := flag.String("packs", "", "community product packs: directories of packs, separated by "+string(filepath.ListSeparator)+
" (default: %APPDATA%/T5DE/packs and $T5DE_PACKS)")
data := flag.String("data", "", "where the proxy keeps its state (default: %APPDATA%/T5DE/proxy, 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")
flag.Parse()
if *version {
@@ -40,6 +72,10 @@ func main() {
var out io.Writer = os.Stderr
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)
if err != nil {
os.Exit(1)
@@ -48,13 +84,38 @@ func main() {
out = f
}
logger := log.New(out, "", log.Ldate|log.Ltime|log.Lmicroseconds)
if err := run(logger, *listen, *ready, *parentPID, *trace); err != nil {
if err := run(logger, *listen, *ready, *parentPID, *trace, packDirs(*packs), dataDir(*data), *channel); err != nil {
logger.Printf("t5de-proxy: %v", err)
os.Exit(1)
}
}
func run(logger *log.Logger, listen, ready string, parentPID int, trace bool) error {
// packDirs is where community product packs are: the -packs flag, else T5DE's packs folder and $T5DE_PACKS.
func packDirs(flagValue string) []string {
if flagValue != "" {
return filepath.SplitList(flagValue)
}
var dirs []string
if config, err := os.UserConfigDir(); err == nil {
dirs = append(dirs, filepath.Join(config, "T5DE", "packs"))
}
return append(dirs, filepath.SplitList(os.Getenv("T5DE_PACKS"))...)
}
func dataDir(flagValue string) string {
if flagValue != "" {
return flagValue
}
if config, err := os.UserConfigDir(); err == nil {
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)
if _, err := rand.Read(secret); err != nil {
return err
@@ -78,8 +139,95 @@ func run(logger *log.Logger, listen, ready string, parentPID int, trace bool) er
os.Exit(0)
}()
}
handler := &proxy.Server{Token: token, Log: logger, Trace: trace}
// 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{
Handler: &proxy.Server{Token: token, Log: logger, Trace: trace},
Handler: handler,
ReadHeaderTimeout: 30 * time.Second,
IdleTimeout: 2 * time.Minute,
ErrorLog: logger,
@@ -87,6 +235,38 @@ func run(logger *log.Logger, listen, ready string, parentPID int, trace bool) er
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 {
data, err := json.Marshal(map[string]string{"address": address, "token": token})
if err != nil {
@@ -98,3 +278,37 @@ func writeReady(path, address, token string) error {
}
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()
}
}
+6
View File
@@ -1,3 +1,9 @@
module git.t5de.dev/t5de/t5de-proxy
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
)
+6
View File
@@ -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=
+112
View File
@@ -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
}
+50
View File
@@ -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)
}
}
+313
View File
@@ -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
}
+172
View File
@@ -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)
}
}
+114
View File
@@ -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)
}
+108
View File
@@ -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)
}
}
+323
View File
@@ -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...)
}
+143
View File
@@ -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
+443
View File
@@ -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)
}
+227
View File
@@ -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}}})
}
+159
View File
@@ -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")
}
}
+470
View File
@@ -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))
}
}
+624
View File
@@ -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
}
+7
View File
@@ -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 }
+31
View File
@@ -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
}
+9 -1
View File
@@ -40,10 +40,11 @@ func TypeName(t uint64) string {
return fmt.Sprintf("type %d", t)
}
// Frame is one message: its type and the size of its data.
// Frame is one message: its type and data (Size is len(Data)).
type Frame struct {
Type uint64
Size int
Data []byte
}
// ErrMalformed means the stream isn't IMQ framing; a Reader stops decoding after it.
@@ -76,6 +77,12 @@ func (r *Reader) Write(data []byte) ([]Frame, error) {
}
}
// Next reads the frame at the start of b: the frame and its length in b, a length of 0 if b holds only part of it,
// or ErrMalformed.
func Next(b []byte) (Frame, int, error) {
return parse(b)
}
// maxFrame bounds a frame's size, so a stream that isn't IMQ isn't buffered without end.
const maxFrame = 16 << 20
@@ -116,6 +123,7 @@ func parse(b []byte) (Frame, int, error) {
return f, 0, nil
}
f.Size = int(size)
f.Data = b[i : i+int(size) : i+int(size)]
return f, i + int(size), nil
default:
return f, 0, ErrMalformed
+50 -2
View File
@@ -36,12 +36,12 @@ func TestReaderSplitsFrames(t *testing.T) {
}
got = append(got, frames...)
}
want := []Frame{{1302, 5}, {3103, 300}, {1301, 0}}
want := []Frame{{1302, 5, []byte("hello")}, {3103, 300, bytes.Repeat([]byte{1}, 300)}, {1301, 0, nil}}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
if got[i].Type != want[i].Type || got[i].Size != want[i].Size || !bytes.Equal(got[i].Data, want[i].Data) {
t.Fatalf("got %v, want %v", got, want)
}
}
@@ -63,3 +63,51 @@ func TestTypeName(t *testing.T) {
t.Fatal(TypeName(3104), TypeName(7))
}
}
func TestSendMessageRoundTrip(t *testing.T) {
m := SendMessage{OpID: 300, Queue: "/chat/1", Mount: "messages", Message: []byte(`{"message": "*use 80 1"}`)}
data := m.Marshal()
got, err := ParseSendMessage(data)
if err != nil || got.OpID != 300 || got.Queue != "/chat/1" || got.Mount != "messages" || string(got.Message) != string(m.Message) {
t.Fatalf("%+v %v", got, err)
}
// A frame of it reads back as one frame with the same data.
var r Reader
frames, err := r.Write(EncodeFrame(C2gSendMessage, data))
if err != nil || len(frames) != 1 || frames[0].Type != C2gSendMessage || !bytes.Equal(frames[0].Data, data) {
t.Fatalf("%v %v", frames, err)
}
if _, err := ParseSendMessage(Result(1, 0)); err == nil {
t.Fatal("a G2cResult parsed as a C2gSendMessage")
}
}
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")
}
}
+357
View File
@@ -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
}
+83
View File
@@ -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
}
+46
View File
@@ -0,0 +1,46 @@
package products
import (
"fmt"
"testing"
)
func community(id int64) bool { return id >= 2000000000 }
func TestStripCommand(t *testing.T) {
for _, c := range []struct {
in, out string
empty, outfit bool
products string
}{
{"*use 80 123 2000000001 456", "*use 80 123 456", false, true, "[2000000001]"},
{"*use 2000000001", "*use", true, false, "[2000000001]"},
{"*putOnOutfit 5 2000000002 2000000003", "*putOnOutfit 5", false, false, "[2000000002 2000000003]"},
{"*remove 2000000001", "*remove", true, false, "[2000000001]"},
{"*use 80 123", "*use 80 123", false, true, "[]"},
{"hello 2000000001", "hello 2000000001", false, false, "[]"},
{"*imvu:setRoomState c...", "*imvu:setRoomState c...", false, false, "[]"},
} {
r := stripCommand(c.in, community)
if r.text != c.out || r.empty != c.empty || r.outfit != c.outfit || fmt.Sprint(r.products) != c.products {
t.Errorf("%q: got %+v", c.in, r)
}
if r.changed != (len(r.products) > 0) {
t.Errorf("%q: changed %v", c.in, r.changed)
}
}
}
func TestChatMessage(t *testing.T) {
m, ok := parseChatMessage([]byte(`{"to": 0, "message": "*use 80 2000000001", "userId": 392, "chatId": 77}`))
if !ok || m.text != "*use 80 2000000001" || m.userID != 392 {
t.Fatalf("%+v %v", m, ok)
}
out := string(m.withText("*use 80"))
if out != `{"chatId":77,"message":"*use 80","to":0,"userId":392}` {
t.Fatal(out)
}
if _, ok := parseChatMessage([]byte(`not json`)); ok {
t.Fatal("parsed non-JSON")
}
}
+689
View File
@@ -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}$`)
+173
View File
@@ -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)
}
}
+41
View File
@@ -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
}
+32
View File
@@ -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
}
+22
View File
@@ -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
}
+16
View File
@@ -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)
}
}
}
+767
View File
@@ -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
}
+175
View File
@@ -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
}
+42
View File
@@ -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)
}
}
+421
View File
@@ -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("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&apos;")
b.WriteString(r.Replace(s))
}
// productdata matches IMVU's product file addresses: /productdata/<id>/<revision>[/<file>].
var productdataPath = regexp.MustCompile(`^/productdata/([0-9]+)/([0-9]+)(?:/(.+))?$`)
// cflURL is where the client fetches a community product's CFL: IMVU's address form, answered by the proxy.
func cflURL(local int64, revision int) string {
return fmt.Sprintf("http://userimages-akm.imvu.com/productdata/%d/%d", local, revision)
}
// serveFile answers a request for a community product's files.
func (p *Products) serveFile(path string) (*http.Response, bool) {
m := productdataPath.FindStringSubmatch(path)
if m == nil {
return nil, false
}
local, _ := strconv.ParseInt(m[1], 10, 64)
if !IsCommunity(local) {
return nil, false
}
s := p.lookup(local)
rev, _ := strconv.Atoi(m[2])
if s == nil || rev != s.pack.Manifest.Revision {
return notFound(), true
}
if m[3] == "" {
resp := localResponse(200, "application/octet-stream", s.cfl)
resp.Header.Set("Content-MD5", s.checksum)
return resp, true
}
for _, f := range s.pack.Files {
if f.Name == m[3] {
return localResponse(200, "application/octet-stream", f.Data), true
}
}
return notFound(), true
}
func notFound() *http.Response {
return localResponse(404, "text/plain", []byte("t5de: no such community product file"))
}
// wornLocal is the user's community products (local IDs, still served) that go with the IMVU outfit look.
func (p *Products) wornLocal(user string, look []int64) []int64 {
var ids []int64
for _, id := range p.state.worn(user, look) {
if s := p.byProductID(id); s != nil {
ids = append(ids, s.local)
}
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}
// recordWorn remembers the user's outfit: the IMVU products sent to IMVU, and the community products (local IDs)
// taken out.
func (p *Products) recordWorn(user string, imvu, locals []int64) {
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
}
+427
View File
@@ -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 &lt;Coat&gt;</ProductName>")) {
t.Fatalf("index.xml %s", entries[0].Data)
}
if status, _ := h.do("GET", strings.Replace(cflAddress, "/1", "/2", 1), nil, false, false); status != 404 {
t.Fatalf("another revision: %d", status)
}
// Saving an outfit with it: IMVU gets the outfit without it; loading the outfit adds it back.
h.do("POST", "http://client-dynamic.imvu.com/api/base_look.php", []byte(fmt.Sprintf(`{"pids": [80, 1, %d]}`, local)), true, true)
if saved := h.imvu.asked["save look"]; len(saved) != 1 || saved[0] != `{"pids":[80,1]}` {
t.Fatalf("IMVU saved %v", saved)
}
_, body = h.do("GET", "http://client-dynamic.imvu.com/api/base_look.php", nil, true, true)
if string(body) != fmt.Sprintf(`{"result":[80,1,%d]}`, local) {
t.Fatalf("look %s", body)
}
_, body = h.do("POST", "https://secure.imvu.com/api/xmlrpc/client.php", call("test.avatarInfoForLogin2"), false, false)
if !bytes.Contains(body, []byte(fmt.Sprintf("<int>%d</int>", local))) {
t.Fatalf("login look %s", body)
}
// If the outfit IMVU has changes (saved elsewhere), it comes back without community products.
h.imvu.look = []int64{80, 2}
_, body = h.do("GET", "http://client-dynamic.imvu.com/api/base_look.php", nil, true, true)
if string(body) != `{"result":[80,2]}` {
t.Fatalf("changed look %s", body)
}
// The shop's bundle lookup is answered without IMVU.
if _, body := h.do("GET", fmt.Sprintf("http://client-dynamic.imvu.com/api/shop/product.php?pid=%d", local), nil, true, true); string(body) != `{"products":[]}` {
t.Fatalf("product.php %s", body)
}
}
func TestOutgoingChat(t *testing.T) {
h, _ := newHarness(t)
local := h.p.byProductID("blue-coat").local
msg := func(text string) []byte {
return []byte(fmt.Sprintf(`{"to": 0, "message": %q, "userId": 7, "chatId": 9}`, text))
}
out, drop := h.p.Outgoing("/chat/9", "messages", msg(fmt.Sprintf("*use 80 1 %d", local)))
if drop || !bytes.Contains(out, []byte(`"message":"*use 80 1"`)) {
t.Fatalf("%s %v", out, drop)
}
if got := h.p.wornLocal(user, []int64{1, 80}); len(got) != 1 || got[0] != local {
t.Fatalf("worn %v", got)
}
// With a different IMVU outfit (changed elsewhere), community products aren't added to it.
if got := h.p.wornLocal(user, []int64{80, 2}); len(got) != 0 {
t.Fatalf("worn with another outfit %v", got)
}
if _, drop := h.p.Outgoing("/chat/9", "messages", msg(fmt.Sprintf("*use %d", local))); !drop {
t.Fatal("a command with only community products was sent")
}
if out, _ := h.p.Outgoing("/chat/9", "messages", msg("hello")); !bytes.Equal(out, msg("hello")) {
t.Fatal("plain chat changed")
}
if out, _ := h.p.Outgoing("room_occupancy/1", "occupants", []byte("x")); string(out) != "x" {
t.Fatal("another queue changed")
}
}
func TestPackChangesAreServed(t *testing.T) {
h, packs := newHarness(t)
before := h.p.byProductID("blue-coat")
os.WriteFile(filepath.Join(packs, "blue-coat", "files", "tex.png"), []byte("a new texture, longer"), 0o644)
h.p.refresh(true)
after := h.p.byProductID("blue-coat")
if after.checksum == before.checksum || after.local != before.local {
t.Fatalf("checksum %s -> %s, local %d -> %d", before.checksum, after.checksum, before.local, after.local)
}
os.RemoveAll(filepath.Join(packs, "blue-coat"))
h.p.refresh(true)
if h.p.byProductID("blue-coat") != nil {
t.Fatal("a removed pack is still served")
}
}
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)
}
}
+78
View File
@@ -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
}
+84
View File
@@ -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)
}
}
}
+421
View File
@@ -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
}
+126
View File
@@ -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)
}
}
+431
View File
@@ -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)
}
+157
View File
@@ -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")
}
}
+68
View File
@@ -0,0 +1,68 @@
package products
import (
"crypto/md5"
"encoding/hex"
"net/http"
"regexp"
"sync"
)
// IMVU signs the client's requests: X-imvu-auth is md5(user ID + session security key + body), with the query
// string added for some calls. The proxy re-signs requests it changes (taking community products out), with the key
// from the login response. The key never leaves this machine.
func sign(user, key string, body []byte, query string, withQuery bool) string {
h := md5.New()
h.Write([]byte(user + key))
h.Write(body)
if withQuery {
h.Write([]byte(query))
}
return hex.EncodeToString(h.Sum(nil))
}
// keys holds each signed-in user's security key.
type keys struct {
mu sync.Mutex
byID map[string]string
}
var securityKeyPattern = regexp.MustCompile(`<name>securityKey</name>\s*<value>(?:<string>)?([0-9A-Za-z]+)`)
var userIDPattern = regexp.MustCompile(`<name>userId</name>\s*<value>(?:<int>|<i4>|<string>)?([0-9]+)`)
// learn takes the user's key from a login response (test.avatarInfoForLogin2).
func (k *keys) learn(body []byte) {
key, user := securityKeyPattern.FindSubmatch(body), userIDPattern.FindSubmatch(body)
if key == nil || user == nil {
return
}
k.mu.Lock()
defer k.mu.Unlock()
if k.byID == nil {
k.byID = map[string]string{}
}
k.byID[string(user[1])] = string(key[1])
}
// resign recomputes X-imvu-auth for a request whose body or query changed, in the scheme the original used. It
// reports false if it couldn't (no key, or the original signature didn't match either scheme).
func (k *keys) resign(h http.Header, oldBody []byte, oldQuery string, newBody []byte, newQuery string) bool {
auth, user := h.Get("X-imvu-auth"), h.Get("X-imvu-userid")
if auth == "" {
return true // not signed
}
k.mu.Lock()
key, ok := k.byID[user]
k.mu.Unlock()
if !ok {
return false
}
for _, withQuery := range []bool{false, true} {
if sign(user, key, oldBody, oldQuery, withQuery) == auth {
h.Set("X-imvu-auth", sign(user, key, newBody, newQuery, withQuery))
return true
}
}
return false
}
+187
View File
@@ -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
View File
@@ -65,8 +65,18 @@ func (s *Server) serveConnect(w http.ResponseWriter, r *http.Request) {
client = io.MultiReader(bytes.NewReader(bytes.Clone(head)), conn)
}
up := &stream{s: s, id: id, name: "c2g"}
// Frames go to the client whole, from the gateway or from the proxy answering a dropped message, so they
// never interleave.
toClient := &lockedWriter{w: conn}
down := &stream{s: s, id: id, name: "g2c"}
up := &stream{s: s, id: id, name: "c2g", filter: s.IMQFilter, reply: toClient}
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.
var once sync.Once
closeBoth := func() {
@@ -85,13 +95,76 @@ func (s *Server) serveConnect(w http.ResponseWriter, r *http.Request) {
go func() {
defer wg.Done()
defer closeBoth()
down.copy(conn, upstream)
down.copy(toClient, upstream)
}()
wg.Wait()
s.logf("#%d CONNECT %s closed after %s: %s, %s", id, target, since(start), up.summary(), down.summary())
}
// stream is one direction of a CONNECT, counting bytes and IMQ frames as they pass.
// IMQFilter sees the client's outgoing IMQ messages and can change or drop them.
type IMQFilter interface {
// Outgoing returns the message to send instead of message, or drop to send nothing and tell the client it
// was sent.
Outgoing(queue, mount string, message []byte) (out []byte, drop bool)
}
// 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 {
s *Server
id int64
@@ -99,7 +172,11 @@ type stream struct {
bytes int64
frames int
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) {
@@ -107,8 +184,8 @@ func (st *stream) copy(dst io.Writer, src io.Reader) {
for {
n, err := src.Read(buf)
if n > 0 {
st.observe(buf[:n])
if _, werr := dst.Write(buf[:n]); werr != nil {
st.bytes += int64(n)
if werr := st.pass(dst, buf[:n]); werr != nil {
return
}
}
@@ -118,24 +195,130 @@ func (st *stream) copy(dst io.Writer, src io.Reader) {
}
}
func (st *stream) observe(data []byte) {
st.bytes += int64(len(data))
// pass writes on the frames data completes.
func (st *stream) pass(dst io.Writer, data []byte) error {
if st.other {
return
}
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)
}
_, err := dst.Write(data)
return err
}
st.pending = append(st.pending, data...)
var out []byte
for {
f, n, err := imq.Next(st.pending)
if err != nil {
st.other = true
st.s.logf("#%d %s isn't IMQ framing; passing it through", st.id, st.name)
out = append(out, st.pending...)
st.pending = nil
break
}
if n == 0 {
break
}
frame := st.pending[:n]
st.pending = st.pending[n:]
st.frames++
if st.s.Trace {
st.s.logf("#%d %s %s %dB", st.id, st.name, imq.TypeName(f.Type), f.Size)
}
out = append(out, st.filtered(f, frame)...)
}
if len(st.pending) == 0 {
st.pending = nil
}
if len(out) == 0 {
return nil
}
_, err := dst.Write(out)
return err
}
// filtered is the frame to send on: as is, changed by the filter, or none (answered here).
func (st *stream) filtered(f imq.Frame, frame []byte) []byte {
if st.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 {
if st.other {
return fmt.Sprintf("%s %dB", st.name, st.bytes)
+33
View File
@@ -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
}
+128
View File
@@ -14,6 +14,7 @@ package proxy
import (
"bytes"
"compress/gzip"
"context"
"crypto/subtle"
"crypto/tls"
@@ -24,6 +25,7 @@ import (
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync/atomic"
"time"
@@ -45,6 +47,10 @@ type Server struct {
// TLSConfig is the base TLS configuration of CONNECT's upstream connections (tests set RootCAs); nil is the
// default, with the system's roots.
TLSConfig *tls.Config
// Interceptor, if set, sees every HTTP request (with a body the proxy could hold in memory).
Interceptor Interceptor
// IMQFilter, if set, sees the client's outgoing IMQ messages (on CONNECT streams) and can change or drop them.
IMQFilter IMQFilter
// Dial makes CONNECT's upstream connections (tests point it at local servers); nil is a net.Dialer.
Dial func(ctx context.Context, network, addr string) (net.Conn, error)
@@ -169,6 +175,29 @@ func (s *Server) forward(w http.ResponseWriter, r *http.Request) {
http.Error(w, "t5de-proxy: "+err.Error(), http.StatusBadRequest)
return
}
var modify func(*http.Response, []byte) ([]byte, error)
if s.Interceptor != nil && replayable {
x := &Exchange{URL: r.URL, Request: out}
if out.GetBody != nil {
body, _ := out.GetBody()
x.Body, _ = io.ReadAll(body)
}
original := x.Body
if err := s.Interceptor.Intercept(x); err != nil {
s.logf("#%d %s %s: %v", id, r.Method, s.logURL(r.URL), err)
w.Header().Set(ErrorHeader, err.Error())
http.Error(w, "t5de-proxy: "+err.Error(), http.StatusBadGateway)
return
}
if x.Response != nil {
s.writeLocal(w, id, r, x.Response, start)
return
}
if !bytes.Equal(x.Body, original) {
setBody(out, x.Body)
}
modify = x.ModifyResponse
}
resp, err := s.transport().RoundTrip(out)
if err != nil && upgraded && replayable && r.Context().Err() == nil {
@@ -191,6 +220,11 @@ func (s *Server) forward(w http.ResponseWriter, r *http.Request) {
return
}
defer resp.Body.Close()
if modify != nil {
if err := modifyResponse(resp, modify); err != nil {
s.logf("#%d %s %s: changing the response: %v", id, r.Method, s.logURL(r.URL), err)
}
}
removeHopHeaders(resp.Header)
for name, values := range resp.Header {
@@ -208,6 +242,85 @@ func (s *Server) forward(w http.ResponseWriter, r *http.Request) {
s.logf("#%d %s %s %d %dB %s%s", id, r.Method, s.logURL(r.URL), resp.StatusCode, n, since(start), note)
}
// Interceptor can answer requests itself, change them before they're sent, and change their responses. The
// t5de-proxy's community products are served this way.
type Interceptor interface {
Intercept(x *Exchange) error
}
// Exchange is a request on its way, as an Interceptor sees it.
type Exchange struct {
// URL is the URL the client asked for (before any upgrade to HTTPS).
URL *url.URL
// Request is the request about to be sent; its body is Body.
Request *http.Request
// Body is the request's body; an Interceptor may replace it.
Body []byte
// Response, if an Interceptor sets it, answers the request: nothing is sent.
Response *http.Response
// ModifyResponse, if set, changes the server's response body (decompressed) before the client gets it.
ModifyResponse func(resp *http.Response, body []byte) ([]byte, error)
}
// LocalResponse is a response made by the proxy.
func LocalResponse(status int, contentType string, body []byte) *http.Response {
h := http.Header{}
h.Set("Content-Type", contentType)
h.Set("Content-Length", strconv.Itoa(len(body)))
return &http.Response{StatusCode: status, Header: h, ContentLength: int64(len(body)), Body: io.NopCloser(bytes.NewReader(body))}
}
func (s *Server) writeLocal(w http.ResponseWriter, id int64, r *http.Request, resp *http.Response, start time.Time) {
defer resp.Body.Close()
for name, values := range resp.Header {
w.Header()[name] = values
}
w.WriteHeader(resp.StatusCode)
n, _ := io.Copy(w, resp.Body)
s.logf("#%d %s %s %d %dB %s (answered by the proxy)", id, r.Method, s.logURL(r.URL), resp.StatusCode, n, since(start))
}
func setBody(out *http.Request, body []byte) {
out.ContentLength = int64(len(body))
out.Header.Set("Content-Length", strconv.Itoa(len(body)))
out.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil }
out.Body, _ = out.GetBody()
if len(body) == 0 {
out.Body, out.GetBody = nil, nil
}
}
// modifyResponse replaces resp's body with modify's version of it, uncompressed.
func modifyResponse(resp *http.Response, modify func(*http.Response, []byte) ([]byte, error)) error {
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBuffered))
resp.Body.Close()
if err != nil {
resp.Body = io.NopCloser(bytes.NewReader(raw))
return err
}
body := raw
if strings.EqualFold(resp.Header.Get("Content-Encoding"), "gzip") {
zr, err := gzip.NewReader(bytes.NewReader(raw))
if err == nil {
body, err = io.ReadAll(io.LimitReader(zr, maxBuffered))
}
if err != nil {
resp.Body = io.NopCloser(bytes.NewReader(raw))
return err
}
}
changed, err := modify(resp, body)
if err != nil {
changed = body
}
resp.Header.Del("Content-Encoding")
resp.Header.Del("Content-Md5")
resp.Header.Set("Content-Length", strconv.Itoa(len(changed)))
resp.ContentLength = int64(len(changed))
resp.Body = io.NopCloser(bytes.NewReader(changed))
return err
}
// maxBuffered bounds the request bodies the proxy holds in memory; larger ones (uploads) are streamed.
const maxBuffered = 32 << 20
@@ -261,3 +374,18 @@ func copyFlushing(w http.ResponseWriter, body io.Reader) (int64, error) {
func since(t time.Time) 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
}
+235
View File
@@ -3,6 +3,7 @@ package proxy
import (
"bufio"
"bytes"
"compress/gzip"
"context"
"crypto/tls"
"encoding/base64"
@@ -18,6 +19,8 @@ import (
"sync"
"testing"
"time"
"git.t5de.dev/t5de/t5de-proxy/internal/imq"
)
const token = "secret"
@@ -340,3 +343,235 @@ func TestFallsBackToHTTPWithTheBody(t *testing.T) {
t.Fatalf("%d %q", resp.StatusCode, got)
}
}
// suffixFilter appends "!" to messages on mount "messages" and drops those saying "drop".
type suffixFilter struct{}
func (suffixFilter) Outgoing(queue, mount string, message []byte) ([]byte, bool) {
if string(message) == "drop" {
return nil, true
}
if mount == "messages" {
return append(bytes.Clone(message), '!'), false
}
return message, false
}
func TestConnectFiltersOutgoingMessages(t *testing.T) {
gateway := httptest.NewUnstartedServer(nil)
gateway.StartTLS()
gateway.Close()
ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: gateway.TLS.Certificates})
if err != nil {
t.Fatal(err)
}
defer ln.Close()
received := make(chan []byte, 1)
go func() {
c, err := ln.Accept()
if err != nil {
return
}
defer c.Close()
var got []byte
buf := make([]byte, 4096)
c.SetReadDeadline(time.Now().Add(2 * time.Second))
for {
n, err := c.Read(buf)
got = append(got, buf[:n]...)
if err != nil {
break
}
}
received <- got
}()
roots := gateway.Client().Transport.(*http.Transport).TLSClientConfig.RootCAs
p, logs := newProxy(t, &Server{TLSConfig: &tls.Config{RootCAs: roots}, IMQFilter: suffixFilter{},
Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
return net.Dial(network, ln.Addr().String())
}})
conn, err := net.Dial("tcp", p.Listener.Addr().String())
if err != nil {
t.Fatal(err)
}
defer conn.Close()
send := func(op uint64, mount, msg string) []byte {
return imq.EncodeFrame(imq.C2gSendMessage, imq.SendMessage{OpID: op, Queue: "/chat/1", Mount: mount, Message: []byte(msg)}.Marshal())
}
// Sent in pieces, to check frames are put back together.
stream := append(append(send(1, "messages", "hi"), send(2, "messages", "drop")...), send(3, "control", "x")...)
fmt.Fprintf(conn, "CONNECT example.com:443 HTTP/1.1\r\nProxy-authorization: %s\r\n\r\n", auth)
r := bufio.NewReader(conn)
for line, _ := r.ReadString('\n'); line != "\r\n"; line, _ = r.ReadString('\n') {
if line == "" {
t.Fatalf("no response; log %s", logs)
}
}
for i := 0; i < len(stream); i += 5 {
conn.Write(stream[i:min(i+5, len(stream))])
time.Sleep(time.Millisecond)
}
// The dropped message is answered by the proxy.
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
var fromProxy []byte
buf := make([]byte, 64)
for len(fromProxy) < len(imq.EncodeFrame(imq.G2cResult, imq.Result(2, 0))) {
n, err := r.Read(buf)
if err != nil {
t.Fatalf("%v; log %s", err, logs)
}
fromProxy = append(fromProxy, buf[:n]...)
}
if !bytes.Equal(fromProxy, imq.EncodeFrame(imq.G2cResult, imq.Result(2, 0))) {
t.Fatalf("client got %x", fromProxy)
}
conn.Close()
got := <-received
// 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)
}
}
+525
View File
@@ -0,0 +1,525 @@
// Package xmlrpc parses and writes XML-RPC calls and responses, keeping
// value types and struct member order exactly.
package xmlrpc
import (
"bytes"
"encoding/base64"
"encoding/xml"
"errors"
"fmt"
"io"
"strconv"
"strings"
)
// MaxSize is the largest document accepted.
const MaxSize = 64 << 20
// MaxDepth is the deepest nesting of arrays and structs accepted.
const MaxDepth = 64
// Value is one of: int64, bool, string, float64, []byte (base64), DateTime,
// []Value (array), Struct, or nil (<nil/>).
type Value = any
// DateTime is dateTime.iso8601 text, kept as is.
type DateTime string
// Member is a struct member.
type Member struct {
Name string
Value Value
}
// Struct is an XML-RPC struct, in document order.
type Struct []Member
// Get returns the value of the first member with the name.
func (s Struct) Get(name string) (Value, bool) {
for _, m := range s {
if m.Name == name {
return m.Value, true
}
}
return nil, false
}
// Call is a methodCall.
type Call struct {
Method string
Params []Value
}
// Response is a methodResponse: Params, or Fault if it's set.
type Response struct {
Params []Value
Fault *Fault
}
// Fault is a fault response's code and message.
type Fault struct {
Code int64
String string
}
func (f *Fault) Error() string { return fmt.Sprintf("xmlrpc fault %d: %s", f.Code, f.String) }
type parser struct{ d *xml.Decoder }
func newParser(data []byte) (*parser, error) {
if len(data) > MaxSize {
return nil, errors.New("xmlrpc: document too large")
}
d := xml.NewDecoder(bytes.NewReader(data))
d.Strict = true
d.CharsetReader = func(label string, r io.Reader) (io.Reader, error) {
switch strings.ToLower(label) {
case "utf-8", "utf8", "us-ascii", "ascii":
return r, nil
}
return nil, fmt.Errorf("xmlrpc: unsupported encoding %q", label)
}
return &parser{d}, nil
}
func (p *parser) token() (xml.Token, error) {
t, err := p.d.Token()
if err == io.EOF {
err = io.ErrUnexpectedEOF
}
return t, err
}
// next returns the next start or end element, skipping whitespace,
// comments and processing instructions.
func (p *parser) next() (xml.Token, error) {
for {
t, err := p.token()
if err != nil {
return nil, err
}
switch t := t.(type) {
case xml.StartElement, xml.EndElement:
return t, nil
case xml.CharData:
if len(bytes.TrimSpace(t)) != 0 {
return nil, fmt.Errorf("xmlrpc: unexpected text %q", string(t))
}
}
}
}
func (p *parser) start(name string) error {
t, err := p.next()
if err != nil {
return err
}
if s, ok := t.(xml.StartElement); !ok || s.Name.Local != name {
return fmt.Errorf("xmlrpc: expected <%s>", name)
}
return nil
}
func (p *parser) end(name string) error {
t, err := p.next()
if err != nil {
return err
}
if e, ok := t.(xml.EndElement); !ok || e.Name.Local != name {
return fmt.Errorf("xmlrpc: expected </%s>", name)
}
return nil
}
// text reads character data up to the current element's end.
func (p *parser) text() (string, error) {
var b strings.Builder
for {
t, err := p.token()
if err != nil {
return "", err
}
switch t := t.(type) {
case xml.CharData:
b.Write(t)
case xml.EndElement:
return b.String(), nil
case xml.StartElement:
return "", fmt.Errorf("xmlrpc: unexpected <%s>", t.Name.Local)
}
}
}
// eof checks that only whitespace follows the root element.
func (p *parser) eof() error {
for {
t, err := p.d.Token()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
switch t := t.(type) {
case xml.StartElement, xml.EndElement:
return errors.New("xmlrpc: content after document")
case xml.CharData:
if len(bytes.TrimSpace(t)) != 0 {
return errors.New("xmlrpc: content after document")
}
}
}
}
// value parses a value's content; its <value> start was read.
// Bare text is a string, whitespace included.
func (p *parser) value(depth int) (Value, error) {
var text strings.Builder
for {
t, err := p.token()
if err != nil {
return nil, err
}
switch t := t.(type) {
case xml.CharData:
text.Write(t)
case xml.EndElement:
return text.String(), nil
case xml.StartElement:
if strings.TrimSpace(text.String()) != "" {
return nil, errors.New("xmlrpc: text mixed with elements in <value>")
}
v, err := p.typed(t.Name.Local, depth)
if err != nil {
return nil, err
}
return v, p.end("value")
}
}
}
func (p *parser) typed(name string, depth int) (Value, error) {
switch name {
case "array", "struct":
if depth >= MaxDepth {
return nil, errors.New("xmlrpc: nested too deeply")
}
if name == "array" {
return p.array(depth + 1)
}
return p.strct(depth + 1)
}
s, err := p.text()
if err != nil {
return nil, err
}
switch name {
case "nil":
return nil, nil
case "string":
return s, nil
case "int", "i4", "i8":
n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
if err != nil {
return nil, fmt.Errorf("xmlrpc: bad <%s> %q", name, s)
}
return n, nil
case "boolean":
switch strings.TrimSpace(s) {
case "0":
return false, nil
case "1":
return true, nil
}
return nil, fmt.Errorf("xmlrpc: bad <boolean> %q", s)
case "double":
f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
if err != nil {
return nil, fmt.Errorf("xmlrpc: bad <double> %q", s)
}
return f, nil
case "dateTime.iso8601":
return DateTime(s), nil
case "base64":
b, err := base64.StdEncoding.DecodeString(strings.Join(strings.Fields(s), ""))
if err != nil {
return nil, fmt.Errorf("xmlrpc: bad <base64>: %v", err)
}
return b, nil
}
return nil, fmt.Errorf("xmlrpc: unknown type <%s>", name)
}
func (p *parser) array(depth int) (Value, error) {
if err := p.start("data"); err != nil {
return nil, err
}
a := []Value{}
for {
t, err := p.next()
if err != nil {
return nil, err
}
switch t := t.(type) {
case xml.EndElement:
return a, p.end("array")
case xml.StartElement:
if t.Name.Local != "value" {
return nil, fmt.Errorf("xmlrpc: unexpected <%s> in <data>", t.Name.Local)
}
v, err := p.value(depth)
if err != nil {
return nil, err
}
a = append(a, v)
}
}
}
func (p *parser) strct(depth int) (Value, error) {
s := Struct{}
for {
t, err := p.next()
if err != nil {
return nil, err
}
switch t := t.(type) {
case xml.EndElement:
return s, nil
case xml.StartElement:
if t.Name.Local != "member" {
return nil, fmt.Errorf("xmlrpc: unexpected <%s> in <struct>", t.Name.Local)
}
if err := p.start("name"); err != nil {
return nil, err
}
name, err := p.text()
if err != nil {
return nil, err
}
if err := p.start("value"); err != nil {
return nil, err
}
v, err := p.value(depth)
if err != nil {
return nil, err
}
if err := p.end("member"); err != nil {
return nil, err
}
s = append(s, Member{name, v})
}
}
}
// params parses <param> elements up to </params>.
func (p *parser) params() ([]Value, error) {
ps := []Value{}
for {
t, err := p.next()
if err != nil {
return nil, err
}
switch t := t.(type) {
case xml.EndElement:
return ps, nil
case xml.StartElement:
if t.Name.Local != "param" {
return nil, fmt.Errorf("xmlrpc: unexpected <%s> in <params>", t.Name.Local)
}
if err := p.start("value"); err != nil {
return nil, err
}
v, err := p.value(0)
if err != nil {
return nil, err
}
if err := p.end("param"); err != nil {
return nil, err
}
ps = append(ps, v)
}
}
}
// ParseCall parses a methodCall document.
func ParseCall(data []byte) (*Call, error) {
p, err := newParser(data)
if err != nil {
return nil, err
}
if err := p.start("methodCall"); err != nil {
return nil, err
}
if err := p.start("methodName"); err != nil {
return nil, err
}
method, err := p.text()
if err != nil {
return nil, err
}
c := &Call{Method: strings.TrimSpace(method), Params: []Value{}}
t, err := p.next()
if err != nil {
return nil, err
}
if s, ok := t.(xml.StartElement); ok && s.Name.Local == "params" {
if c.Params, err = p.params(); err != nil {
return nil, err
}
if err := p.end("methodCall"); err != nil {
return nil, err
}
} else if e, ok := t.(xml.EndElement); !ok || e.Name.Local != "methodCall" {
return nil, errors.New("xmlrpc: expected <params>")
}
return c, p.eof()
}
// ParseResponse parses a methodResponse document.
func ParseResponse(data []byte) (*Response, error) {
p, err := newParser(data)
if err != nil {
return nil, err
}
if err := p.start("methodResponse"); err != nil {
return nil, err
}
t, err := p.next()
if err != nil {
return nil, err
}
s, _ := t.(xml.StartElement)
r := &Response{}
switch s.Name.Local {
case "params":
if r.Params, err = p.params(); err != nil {
return nil, err
}
case "fault":
if r.Fault, err = p.fault(); err != nil {
return nil, err
}
default:
return nil, errors.New("xmlrpc: expected <params> or <fault>")
}
if err := p.end("methodResponse"); err != nil {
return nil, err
}
return r, p.eof()
}
func (p *parser) fault() (*Fault, error) {
if err := p.start("value"); err != nil {
return nil, err
}
v, err := p.value(0)
if err != nil {
return nil, err
}
if err := p.end("fault"); err != nil {
return nil, err
}
st, _ := v.(Struct)
code, _ := st.Get("faultCode")
msg, _ := st.Get("faultString")
f := &Fault{}
var ok1, ok2 bool
f.Code, ok1 = code.(int64)
f.String, ok2 = msg.(string)
if !ok1 || !ok2 {
return nil, errors.New("xmlrpc: bad fault")
}
return f, nil
}
// Marshal writes the call. It panics on a value of an unsupported type.
func (c *Call) Marshal() []byte {
var b bytes.Buffer
b.WriteString("<?xml version='1.0'?>\n<methodCall>\n<methodName>")
xml.EscapeText(&b, []byte(c.Method))
b.WriteString("</methodName>\n")
writeParams(&b, c.Params)
b.WriteString("</methodCall>\n")
return b.Bytes()
}
// Marshal writes the response. It panics on a value of an unsupported type.
func (r *Response) Marshal() []byte {
var b bytes.Buffer
b.WriteString("<?xml version='1.0'?>\n<methodResponse>\n")
if r.Fault != nil {
b.WriteString("<fault>\n")
writeValue(&b, Struct{{"faultCode", r.Fault.Code}, {"faultString", r.Fault.String}})
b.WriteString("</fault>\n")
} else {
writeParams(&b, r.Params)
}
b.WriteString("</methodResponse>\n")
return b.Bytes()
}
func writeParams(b *bytes.Buffer, ps []Value) {
b.WriteString("<params>\n")
for _, v := range ps {
b.WriteString("<param>\n")
writeValue(b, v)
b.WriteString("</param>\n")
}
b.WriteString("</params>\n")
}
// writeText escapes s; xml.EscapeText also escapes quotes and \t\n\r as
// character references, so whitespace survives exactly.
func writeText(b *bytes.Buffer, s string) { xml.EscapeText(b, []byte(s)) }
func writeValue(b *bytes.Buffer, v Value) {
b.WriteString("<value>")
switch v := v.(type) {
case nil:
b.WriteString("<nil/>")
case int64:
fmt.Fprintf(b, "<int>%d</int>", v)
case int:
fmt.Fprintf(b, "<int>%d</int>", v)
case bool:
if v {
b.WriteString("<boolean>1</boolean>")
} else {
b.WriteString("<boolean>0</boolean>")
}
case string:
b.WriteString("<string>")
writeText(b, v)
b.WriteString("</string>")
case float64:
// Shortest round-trip form; Python's float() parses it, NaN and Inf too.
fmt.Fprintf(b, "<double>%s</double>", strconv.FormatFloat(v, 'g', -1, 64))
case []byte:
fmt.Fprintf(b, "<base64>%s</base64>", base64.StdEncoding.EncodeToString(v))
case DateTime:
b.WriteString("<dateTime.iso8601>")
writeText(b, string(v))
b.WriteString("</dateTime.iso8601>")
case []Value:
b.WriteString("<array><data>\n")
for _, e := range v {
writeValue(b, e)
}
b.WriteString("</data></array>")
case Struct:
b.WriteString("<struct>\n")
for _, m := range v {
b.WriteString("<member>\n<name>")
writeText(b, m.Name)
b.WriteString("</name>\n")
writeValue(b, m.Value)
b.WriteString("</member>\n")
}
b.WriteString("</struct>")
default:
panic(fmt.Sprintf("xmlrpc: unsupported type %T", v))
}
b.WriteString("</value>\n")
}
+303
View File
@@ -0,0 +1,303 @@
package xmlrpc
import (
"bytes"
"reflect"
"strings"
"testing"
)
// Python 2.7 xmlrpclib.dumps(..., methodname=...) output, synthetic values.
const authorizeCall = `<?xml version='1.0'?>
<methodCall>
<methodName>test.authorizeProducts2</methodName>
<params>
<param>
<value><struct>
<member>
<name>auths</name>
<value><array><data>
<value><array><data>
<value><string>userId</string></value>
<value><string>productId</string></value>
</data></array></value>
<value><array><data>
<value><int>123</int></value>
<value><int>456</int></value>
</data></array></value>
</data></array></value>
</member>
<member>
<name>use_extracted_assets_if_available</name>
<value><boolean>1</boolean></value>
</member>
<member>
<name>max_supported_format</name>
<value><int>9</int></value>
</member>
</struct></value>
</param>
</params>
</methodCall>
`
// PHP-style response: declaration with encoding, indentation, i4, bare text.
const phpResponse = `<?xml version="1.0" encoding="UTF-8"?>
<methodResponse>
<params>
<param>
<value>
<array>
<data>
<value>
<array>
<data>
<value><i4>1001</i4></value>
<value><string> Spaced &amp; &lt;name&gt; </string></value>
<value><boolean>0</boolean></value>
</data>
</array>
</value>
<value>
<array>
<data>
<value><int>-7</int></value>
<value>bare text</value>
<value><boolean>1</boolean></value>
<value><i8>9007199254740993</i8></value>
<value><double>0.1</double></value>
<value><dateTime.iso8601>20261029T12:34:56</dateTime.iso8601></value>
<value><base64>aGVs
bG8=</base64></value>
<value><nil/></value>
<value></value>
<value/>
<value><string/></value>
<value><struct></struct></value>
<value><array><data></data></array></value>
</data>
</array>
</value>
</data>
</array>
</value>
</param>
</params>
</methodResponse>
`
const faultResponse = `<?xml version="1.0" encoding="UTF-8"?>
<methodResponse>
<fault>
<value>
<struct>
<member><name>faultCode</name><value><int>4</int></value></member>
<member><name>faultString</name><value><string>Too many parameters.</string></value></member>
</struct>
</value>
</fault>
</methodResponse>
`
const unicodeCall = "<?xml version='1.0'?>\n<methodCall><methodName>test.echo</methodName><params>" +
"<param><value><string>café 日本 \U0001F600 &#x263A;</string></value></param>" +
"</params></methodCall>"
func TestParseCall(t *testing.T) {
c, err := ParseCall([]byte(authorizeCall))
if err != nil {
t.Fatal(err)
}
want := &Call{Method: "test.authorizeProducts2", Params: []Value{Struct{
{"auths", []Value{[]Value{"userId", "productId"}, []Value{int64(123), int64(456)}}},
{"use_extracted_assets_if_available", true},
{"max_supported_format", int64(9)},
}}}
if !reflect.DeepEqual(c, want) {
t.Fatalf("got %#v", c)
}
roundTripCall(t, c)
}
func TestParseResponse(t *testing.T) {
r, err := ParseResponse([]byte(phpResponse))
if err != nil {
t.Fatal(err)
}
want := &Response{Params: []Value{[]Value{
[]Value{int64(1001), " Spaced & <name> ", false},
[]Value{int64(-7), "bare text", true, int64(9007199254740993), 0.1,
DateTime("20261029T12:34:56"), []byte("hello"), nil, "", "", "", Struct{}, []Value{}},
}}}
if !reflect.DeepEqual(r, want) {
t.Fatalf("got %#v", r)
}
roundTripResponse(t, r)
}
func TestFault(t *testing.T) {
r, err := ParseResponse([]byte(faultResponse))
if err != nil {
t.Fatal(err)
}
if r.Fault == nil || *r.Fault != (Fault{4, "Too many parameters."}) || r.Params != nil {
t.Fatalf("got %#v", r)
}
out := r.Marshal()
if !bytes.Contains(out, []byte("<methodResponse>\n<fault>")) {
t.Fatalf("marshal: %s", out)
}
roundTripResponse(t, r)
}
func TestUnicode(t *testing.T) {
c, err := ParseCall([]byte(unicodeCall))
if err != nil {
t.Fatal(err)
}
if s := c.Params[0]; s != "café 日本 \U0001F600 ☺" {
t.Fatalf("got %q", s)
}
roundTripCall(t, c)
}
func TestWhitespaceInString(t *testing.T) {
r := &Response{Params: []Value{" a\tb\r\nc ", Struct{{"k\n", "\r"}}, 1.5e300, -0.0}}
roundTripResponse(t, r)
}
func TestMarshalFormat(t *testing.T) {
c := &Call{Method: "m", Params: []Value{int64(1), true, "a<&>", nil, 2.5}}
want := "<?xml version='1.0'?>\n<methodCall>\n<methodName>m</methodName>\n<params>\n" +
"<param>\n<value><int>1</int></value>\n</param>\n" +
"<param>\n<value><boolean>1</boolean></value>\n</param>\n" +
"<param>\n<value><string>a&lt;&amp;&gt;</string></value>\n</param>\n" +
"<param>\n<value><nil/></value>\n</param>\n" +
"<param>\n<value><double>2.5</double></value>\n</param>\n" +
"</params>\n</methodCall>\n"
if got := string(c.Marshal()); got != want {
t.Fatalf("got\n%s", got)
}
}
func TestCallWithoutParams(t *testing.T) {
c, err := ParseCall([]byte("<methodCall><methodName>x</methodName></methodCall>"))
if err != nil || c.Method != "x" || len(c.Params) != 0 {
t.Fatal(c, err)
}
}
func TestErrors(t *testing.T) {
bad := []string{
"",
"<methodCall>",
"<methodResponse><params><param><value><int>x</int></value></param></params></methodResponse>",
"<methodResponse><params><param><value><boolean>2</boolean></value></param></params></methodResponse>",
"<methodResponse><params><param><value><double>z</double></value></param></params></methodResponse>",
"<methodResponse><params><param><value><base64>!!</base64></value></param></params></methodResponse>",
"<methodResponse><params><param><value><foo/></value></param></params></methodResponse>",
"<methodResponse><params><param><value>x<int>1</int></value></param></params></methodResponse>",
"<methodResponse><params><param><value><int>1</int></value></param></params></methodResponse><x/>",
"<methodResponse><fault><value><int>1</int></value></fault></methodResponse>",
"<methodResponse><params><param><value><string>a</value></param></params></methodResponse>",
"<methodResponse></methodResponse>",
"<methodCall><methodName>x</methodName><params>junk</params></methodCall>",
`<?xml version="1.0" encoding="ISO-8859-1"?><methodCall><methodName>x</methodName></methodCall>`,
}
for _, s := range bad {
_, err1 := ParseResponse([]byte(s))
_, err2 := ParseCall([]byte(s))
if err1 == nil && err2 == nil {
t.Errorf("accepted %q", s)
}
}
}
func nested(n int) string {
return "<methodResponse><params><param><value>" +
strings.Repeat("<array><data><value>", n) + "<int>1</int>" +
strings.Repeat("</value></data></array>", n) +
"</value></param></params></methodResponse>"
}
func TestDepth(t *testing.T) {
if _, err := ParseResponse([]byte(nested(MaxDepth))); err != nil {
t.Fatal(err)
}
if _, err := ParseResponse([]byte(nested(MaxDepth + 1))); err == nil {
t.Fatal("accepted too deep")
}
}
func TestSize(t *testing.T) {
big := make([]byte, MaxSize+1)
if _, err := ParseCall(big); err == nil {
t.Fatal("accepted too large")
}
}
func roundTripCall(t *testing.T, c *Call) {
t.Helper()
c2, err := ParseCall(c.Marshal())
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(c, c2) {
t.Fatalf("round trip:\n%#v\n%#v", c, c2)
}
}
func roundTripResponse(t *testing.T, r *Response) {
t.Helper()
r2, err := ParseResponse(r.Marshal())
if err != nil {
t.Fatal(err)
}
if r.Params == nil && r.Fault == nil {
r.Params = []Value{}
}
if !reflect.DeepEqual(r, r2) {
t.Fatalf("round trip:\n%#v\n%#v", r, r2)
}
}
// Fuzzed documents must not panic, and whatever parses must re-marshal stably
// (bytes, not values, so NaN compares equal).
func FuzzParseCall(f *testing.F) {
f.Add([]byte(authorizeCall))
f.Add([]byte(unicodeCall))
f.Fuzz(func(t *testing.T, data []byte) {
c, err := ParseCall(data)
if err != nil {
return
}
out := c.Marshal()
c2, err := ParseCall(out)
if err != nil {
t.Fatalf("reparse: %v\n%s", err, out)
}
if !bytes.Equal(out, c2.Marshal()) {
t.Fatalf("unstable:\n%s", out)
}
})
}
func FuzzParseResponse(f *testing.F) {
f.Add([]byte(phpResponse))
f.Add([]byte(faultResponse))
f.Add([]byte(nested(3)))
f.Fuzz(func(t *testing.T, data []byte) {
r, err := ParseResponse(data)
if err != nil {
return
}
out := r.Marshal()
r2, err := ParseResponse(out)
if err != nil {
t.Fatalf("reparse: %v\n%s", err, out)
}
if !bytes.Equal(out, r2.Marshal()) {
t.Fatalf("unstable:\n%s", out)
}
})
}