Sign in to T5DE's hub and report presence and community outfits
CI / build (push) Successful in 1m6s
CI / build (push) Successful in 1m6s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
50e4db8521
commit
7887eada61
9 files changed
+476
-2
No files matched your search
@@ -54,6 +54,13 @@ products with the outfit they were saved with, and adds them back when the clien
|
|||||||
Requests it changes are re-signed (`X-imvu-auth`) with the session key from the login response, which never
|
Requests it changes are re-signed (`X-imvu-auth`) with the session key from the login response, which never
|
||||||
leaves the machine.
|
leaves the machine.
|
||||||
|
|
||||||
|
## The hub
|
||||||
|
|
||||||
|
`t5de-proxy signin [-hub URL]` signs the proxy in to T5DE's hub (git.t5de.dev/t5de/t5de-hub): it shows a code to
|
||||||
|
enter on the hub's website (`/device`) and saves the sign-in in `%APPDATA%\T5DE\proxy\hub.json`, the refresh
|
||||||
|
token encrypted for the Windows user (DPAPI). From then on the running proxy keeps a WebSocket to the hub
|
||||||
|
(`internal/hub`), reporting which IMVU account the client signed in as and the community products it wears.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
go test ./...
|
go test ./...
|
||||||
|
|||||||
+66
-1
@@ -5,12 +5,19 @@
|
|||||||
//
|
//
|
||||||
// It listens on a free port of 127.0.0.1 and, once it's listening, writes {"address", "token"} to the ready file
|
// 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.
|
// (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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -18,17 +25,28 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.t5de.dev/t5de/t5de-proxy/internal/buildinfo"
|
"git.t5de.dev/t5de/t5de-proxy/internal/buildinfo"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||||
"git.t5de.dev/t5de/t5de-proxy/internal/parent"
|
"git.t5de.dev/t5de/t5de-proxy/internal/parent"
|
||||||
"git.t5de.dev/t5de/t5de-proxy/internal/products"
|
"git.t5de.dev/t5de/t5de-proxy/internal/products"
|
||||||
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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")
|
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")
|
ready := flag.String("ready", "", "file to write the address and token to once listening")
|
||||||
parentPID := flag.Int("parent", 0, "exit when this process exits")
|
parentPID := flag.Int("parent", 0, "exit when this process exits")
|
||||||
@@ -107,12 +125,22 @@ func run(logger *log.Logger, listen, ready string, parentPID int, trace bool, pa
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
handler := &proxy.Server{Token: token, Log: logger, Trace: trace}
|
handler := &proxy.Server{Token: token, Log: logger, Trace: trace}
|
||||||
if prods, err := products.New(packs, data, logger.Printf); err != nil {
|
prods, err := products.New(packs, data, logger.Printf)
|
||||||
|
if err != nil {
|
||||||
logger.Printf("community products are off: %v", err)
|
logger.Printf("community products are off: %v", err)
|
||||||
} else {
|
} else {
|
||||||
handler.Interceptor, handler.IMQFilter = prods, prods
|
handler.Interceptor, handler.IMQFilter = prods, prods
|
||||||
logger.Printf("community products from %s", strings.Join(packs, ", "))
|
logger.Printf("community products from %s", strings.Join(packs, ", "))
|
||||||
}
|
}
|
||||||
|
if saved, err := hub.Load(filepath.Join(data, "hub.json")); err == nil {
|
||||||
|
client := hub.New(filepath.Join(data, "hub.json"), saved, logger)
|
||||||
|
if prods != nil {
|
||||||
|
prods.Observer = client
|
||||||
|
}
|
||||||
|
go client.Run(context.Background())
|
||||||
|
} else if !errors.Is(err, hub.ErrSignedOut) {
|
||||||
|
logger.Printf("hub: %v", err)
|
||||||
|
}
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
ReadHeaderTimeout: 30 * time.Second,
|
ReadHeaderTimeout: 30 * time.Second,
|
||||||
@@ -133,3 +161,40 @@ func writeReady(path, address, token string) error {
|
|||||||
}
|
}
|
||||||
return os.Rename(tmp, path)
|
return os.Rename(tmp, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// defaultHub is T5DE's hub.
|
||||||
|
const defaultHub = "https://hub.t5de.dev"
|
||||||
|
|
||||||
|
func signin(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("signin", flag.ExitOnError)
|
||||||
|
hubURL := fs.String("hub", envOr("T5DE_HUB", defaultHub), "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(dataDir(*data), "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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,4 +2,8 @@ module git.t5de.dev/t5de/t5de-proxy
|
|||||||
|
|
||||||
go 1.27.0
|
go 1.27.0
|
||||||
|
|
||||||
require git.t5de.dev/t5de/t5de-api v0.0.0-20261029101713-106b4fcc6064
|
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
|
||||||
|
)
|
||||||
@@ -1,2 +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 h1:UdRdoSTTZHjUTW7wF9+EVJ7UFgBktXrls3FsMKiOp2Y=
|
||||||
git.t5de.dev/t5de/t5de-api v0.0.0-20261029101713-106b4fcc6064/go.mod h1:mRXJdl43TzJN3JlUeVhi6BSqle37+ALmqzxqTYchXLw=
|
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=
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
// 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, and a live connection reporting which IMVU account the client is using and the
|
||||||
|
// community products it wears.
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saved is the proxy's sign-in, in <data dir>/hub.json. The refresh token is encrypted for the Windows user.
|
||||||
|
type Saved struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
RefreshToken string `json:"-"`
|
||||||
|
Protected []byte `json:"refresh_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.RefreshToken = string(tok)
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Saved) Save(path string) error {
|
||||||
|
p, err := protect([]byte(s.RefreshToken))
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
type tokens struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func post(ctx context.Context, endpoint string, form url.Values) (*tokens, int, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("User-Agent", "t5de-proxy")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var t tokens
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err := json.Unmarshal(body, &t); err != nil {
|
||||||
|
return nil, resp.StatusCode, fmt.Errorf("the hub answered %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return &t, 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, "/")
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "POST", hubURL+"/api/v1/device/code", nil)
|
||||||
|
req.Header.Set("User-Agent", "t5de-proxy")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
err = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&device)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil || device.DeviceCode == "" {
|
||||||
|
return nil, fmt.Errorf("the hub didn't give a sign-in code (%d)", resp.StatusCode)
|
||||||
|
}
|
||||||
|
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):
|
||||||
|
}
|
||||||
|
t, _, err := post(ctx, hubURL+"/api/v1/device/token", url.Values{"device_code": {device.DeviceCode}})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch t.Error {
|
||||||
|
case "":
|
||||||
|
return &Saved{URL: hubURL, RefreshToken: t.RefreshToken}, nil
|
||||||
|
case "authorization_pending", "slow_down":
|
||||||
|
continue
|
||||||
|
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
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
saved *Saved
|
||||||
|
imvuID int64
|
||||||
|
outfit []Item
|
||||||
|
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, wake: make(chan struct{}, 1)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// accessToken trades the refresh token for an access token (and a new refresh token, saved).
|
||||||
|
func (c *Client) accessToken(ctx context.Context) (string, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
saved := *c.saved
|
||||||
|
c.mu.Unlock()
|
||||||
|
t, status, err := post(ctx, saved.URL+"/api/v1/token/refresh", url.Values{"refresh_token": {saved.RefreshToken}})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||||
|
return "", fmt.Errorf("%w: %s", ErrSignedOut, t.Error)
|
||||||
|
}
|
||||||
|
if t.Error != "" || t.AccessToken == "" {
|
||||||
|
return "", fmt.Errorf("the hub answered %d %s", status, t.Error)
|
||||||
|
}
|
||||||
|
saved.RefreshToken = t.RefreshToken
|
||||||
|
if err := saved.Save(c.path); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.saved = &saved
|
||||||
|
c.mu.Unlock()
|
||||||
|
return t.AccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) connect(ctx context.Context) error {
|
||||||
|
access, err := c.accessToken(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
wsURL := strings.Replace(strings.Replace(c.saved.URL, "https://", "wss://", 1), "http://", "ws://", 1) + "/api/v1/realtime"
|
||||||
|
conn, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: http.Header{
|
||||||
|
"Authorization": {"Bearer " + access}, "User-Agent": {"t5de-proxy"}}})
|
||||||
|
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 {
|
||||||
|
errs <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var m struct{ Type, Message string }
|
||||||
|
if json.Unmarshal(data, &m) == nil && m.Type == "error" {
|
||||||
|
c.log.Printf("hub: %s", m.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
// Access tokens are short-lived; reconnecting with a fresh one before it expires keeps the hub's check current.
|
||||||
|
renew := time.After(12 * time.Minute)
|
||||||
|
sentID, sentOutfit := int64(-1), ""
|
||||||
|
for {
|
||||||
|
c.mu.Lock()
|
||||||
|
id, outfit := c.imvuID, append([]Item(nil), c.outfit...)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
conn.Close(websocket.StatusNormalClosure, "")
|
||||||
|
return ctx.Err()
|
||||||
|
case err := <-errs:
|
||||||
|
return err
|
||||||
|
case <-renew:
|
||||||
|
conn.Close(websocket.StatusNormalClosure, "renewing")
|
||||||
|
return errors.New("renewing the access token")
|
||||||
|
case <-c.wake:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -50,6 +50,11 @@ func (p *Products) interceptXMLRPC(x *proxy.Exchange) error {
|
|||||||
case "test.avatarInfoForLogin2":
|
case "test.avatarInfoForLogin2":
|
||||||
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
|
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
|
||||||
p.keys.learn(body)
|
p.keys.learn(body)
|
||||||
|
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)
|
return p.addToLook(body)
|
||||||
}
|
}
|
||||||
case "test.inventoryProductBatch":
|
case "test.inventoryProductBatch":
|
||||||
@@ -90,6 +95,7 @@ func (p *Products) addToLook(body []byte) ([]byte, error) {
|
|||||||
if mem.Name == "look" {
|
if mem.Name == "look" {
|
||||||
if look, ok := productList(mem.Value); ok {
|
if look, ok := productList(mem.Value); ok {
|
||||||
if worn := p.wornLocal(user, look); len(worn) > 0 {
|
if worn := p.wornLocal(user, look); len(worn) > 0 {
|
||||||
|
p.reportOutfit(user, worn)
|
||||||
v[i].Value = appendIDs(mem.Value.([]xmlrpc.Value), worn)
|
v[i].Value = appendIDs(mem.Value.([]xmlrpc.Value), worn)
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
@@ -401,6 +407,7 @@ func (p *Products) interceptBaseLook(x *proxy.Exchange) error {
|
|||||||
return body, err
|
return body, err
|
||||||
}
|
}
|
||||||
worn := p.wornLocal(user, ids)
|
worn := p.wornLocal(user, ids)
|
||||||
|
p.reportOutfit(user, worn)
|
||||||
if len(worn) == 0 {
|
if len(worn) == 0 {
|
||||||
return body, nil
|
return body, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import (
|
|||||||
|
|
||||||
"git.t5de.dev/t5de/t5de-api/cfl"
|
"git.t5de.dev/t5de/t5de-api/cfl"
|
||||||
"git.t5de.dev/t5de/t5de-api/pack"
|
"git.t5de.dev/t5de/t5de-api/pack"
|
||||||
|
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Products is the proxy's community products: an Interceptor for HTTP and an IMQFilter for chat.
|
// Products is the proxy's community products: an Interceptor for HTTP and an IMQFilter for chat.
|
||||||
@@ -42,6 +43,16 @@ type Products struct {
|
|||||||
byLocal map[int64]*served
|
byLocal map[int64]*served
|
||||||
byID map[string]*served
|
byID map[string]*served
|
||||||
problems map[string]string // pack directory -> last error logged
|
problems map[string]string // pack directory -> last error logged
|
||||||
|
|
||||||
|
// Observer, if set, hears which IMVU account the client signs in as and the community products it wears (the
|
||||||
|
// hub client).
|
||||||
|
Observer Observer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observer hears about the client's IMVU account and its community outfit.
|
||||||
|
type Observer interface {
|
||||||
|
Session(imvuID int64)
|
||||||
|
Outfit(imvuID int64, items []hub.Item)
|
||||||
}
|
}
|
||||||
|
|
||||||
// served is a pack ready to serve.
|
// served is a pack ready to serve.
|
||||||
@@ -268,6 +279,7 @@ func (p *Products) wornLocal(user string, look []int64) []int64 {
|
|||||||
// recordWorn remembers the user's outfit: the IMVU products sent to IMVU, and the community products (local IDs)
|
// recordWorn remembers the user's outfit: the IMVU products sent to IMVU, and the community products (local IDs)
|
||||||
// taken out.
|
// taken out.
|
||||||
func (p *Products) recordWorn(user string, imvu, locals []int64) {
|
func (p *Products) recordWorn(user string, imvu, locals []int64) {
|
||||||
|
p.reportOutfit(user, locals)
|
||||||
var ids []string
|
var ids []string
|
||||||
for _, local := range locals {
|
for _, local := range locals {
|
||||||
if id, ok := p.state.productID(local); ok {
|
if id, ok := p.state.productID(local); ok {
|
||||||
@@ -280,3 +292,21 @@ func (p *Products) recordWorn(user string, imvu, locals []int64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var errNoKey = errors.New("products: can't re-sign the request (no session key yet)")
|
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})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Observer.Outfit(id, items)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user