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]>
580 lines
16 KiB
Go
580 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)
|
|
// 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))
|
|
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
|
|
}
|