Files
t5de-proxy/internal/hub/hub.go
T
dhkatzandClaude Opus 5.5 d8332b21a8
CI / build (push) Successful in 36s
Serve T5DE-hosted rooms; a second client instance's own data
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

574 lines
16 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()
// 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)
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 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{}
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))
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 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
}