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]>
520 lines
14 KiB
Go
520 lines
14 KiB
Go
// 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 (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"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()
|
|
|
|
mu sync.Mutex
|
|
saved *Saved
|
|
imvuID int64
|
|
outfit []Item
|
|
chats map[string]string // chat ID -> room instance ("" for other chats)
|
|
wake chan struct{}
|
|
}
|
|
|
|
// 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)}
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
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 m struct {
|
|
Type, Message, Chat string
|
|
Avatars []Avatar
|
|
}
|
|
if json.Unmarshal(data, &m) != nil {
|
|
continue
|
|
}
|
|
switch m.Type {
|
|
case "error":
|
|
c.log.Printf("hub: %s", m.Message)
|
|
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{}
|
|
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)
|
|
}
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
conn.Close(websocket.StatusNormalClosure, "")
|
|
return ctx.Err()
|
|
case err := <-errs:
|
|
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))
|
|
if resp.StatusCode/100 != 2 {
|
|
var e struct{ Error, Message string }
|
|
json.Unmarshal(data, &e)
|
|
return fmt.Errorf("the hub answered %d %s", resp.StatusCode, e.Error)
|
|
}
|
|
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 struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
} `json:"product"`
|
|
StayOn *int `json:"stayOn"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|