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]>
399 lines
10 KiB
Go
399 lines
10 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"
|
|
"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)
|
|
|
|
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)
|
|
|
|
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 "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:
|
|
}
|
|
}
|
|
}
|