Files
t5de-proxy/internal/control/control.go
T
dhkatzandClaude Opus 5.5 8ae45fc07a
CI / build (push) Successful in 18s
Choose the hub: Settings' choice, else the install's channel's; state kept per hub
The client's Settings > T5DE chooses the hub from the next start on (POST /hub/server, kept in server.json);
without a choice, -channel (which the client passes) decides: staging for nightly and dev, T5DE's hub for
releases. $T5DE_HUB still wins. Each hub's sign-in, product IDs and products are kept in their own folder
(T5DE's hub in the data folder as before, others in hubs/<host>), so a hub never gets another's token and
product IDs from two hubs never clash; a sign-in saved for another hub is ignored. Signing in always goes to
this run's hub.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:09:34 -08:00

314 lines
8.7 KiB
Go

// Package control runs the proxy's connection to T5DE's hub, and lets the client's T5DE settings page see and
// change it: requests to http://t5de-proxy.local/ (only the client can make them: they need the proxy's token)
// are answered here.
//
// GET /hub {signedIn, hub, username, signingIn, code, page, error, choice, default, fixed, restart}
// POST /hub/signin starts signing in to this run's hub: opens the hub's page for the code in the browser
// POST /hub/signout signs this computer out
// POST /hub/server hub=<URL>, or empty for the install's default: the hub from the next start on (restart)
// POST /badges {"imvuIds": [...]} -> {"badges": [...]}: which of those IMVU accounts show the T5 badge (their
// T5DE users opted in), from the hub, remembered for a while
package control
import (
"context"
"encoding/json"
"errors"
"log"
"net/url"
"os"
"strings"
"sync"
"time"
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
)
// Host is the address the client uses for the proxy itself.
const Host = "t5de-proxy.local"
// Manager keeps the hub connection running and answers the client's settings page.
type Manager struct {
Path string // hub.json, in the hub's state folder (StateDir)
// Hub is this run's hub (Server); Default the install's (ChannelDefault); ChoicePath where the person's
// choice is kept. Fixed means $T5DE_HUB chose it, so the choice can't.
Hub, Default, ChoicePath string
Fixed bool
Log *log.Logger
// Attach is called with each new hub client before it runs (to connect it to the rest of the proxy).
Attach func(c *hub.Client)
// OpenBrowser opens a page in the person's browser.
OpenBrowser func(page string)
mu sync.Mutex
client *hub.Client
saved *hub.Saved
stop context.CancelFunc
username string
signin *signin
badges map[int64]badgeAnswer
}
// badgeAnswer is whether an IMVU account shows the T5 badge, as the hub said at a time.
type badgeAnswer struct {
shows bool
at time.Time
}
// badgeFor is how long an answer about a badge is kept.
var badgeFor = 10 * time.Minute
// Badges is which of the IMVU accounts show the T5 badge: remembered answers, and the hub asked about the rest.
// Signed out, none do.
func (m *Manager) Badges(ctx context.Context, ids []int64) []int64 {
m.mu.Lock()
c := m.client
if m.badges == nil {
m.badges = map[int64]badgeAnswer{}
}
var out, ask []int64
seen := map[int64]bool{}
for _, id := range ids {
if id <= 0 || seen[id] {
continue
}
seen[id] = true
if a, ok := m.badges[id]; ok && time.Since(a.at) < badgeFor {
if a.shows {
out = append(out, id)
}
} else {
ask = append(ask, id)
}
}
m.mu.Unlock()
if c == nil || len(ask) == 0 {
return out
}
for len(ask) > 0 {
batch := ask[:min(len(ask), 200)]
ask = ask[len(batch):]
var r struct {
Badges []int64 `json:"badges"`
}
if err := c.Call(ctx, "POST", "/api/v1/badges", map[string]any{"imvuIds": batch}, &r); err != nil {
m.Log.Printf("hub: badges: %v", err)
return out
}
shows := map[int64]bool{}
for _, id := range r.Badges {
shows[id] = true
}
now := time.Now()
m.mu.Lock()
for _, id := range batch {
m.badges[id] = badgeAnswer{shows[id], now}
if shows[id] {
out = append(out, id)
}
}
m.mu.Unlock()
}
return out
}
type signin struct {
code, page, err string
running bool
cancel context.CancelFunc
}
// Start connects to the hub if the proxy is signed in.
func (m *Manager) Start() {
saved, err := hub.Load(m.Path)
if err != nil {
if !errors.Is(err, hub.ErrSignedOut) {
m.Log.Printf("hub: %v", err)
}
return
}
if saved.URL != m.Hub {
// Signed in to another hub (by an older proxy, before hubs had their own folders): not this run's.
m.Log.Printf("hub: the sign-in is for %s, not %s; signed out", saved.URL, m.Hub)
return
}
m.run(saved)
}
func (m *Manager) run(saved *hub.Saved) {
c := hub.New(m.Path, saved, m.Log)
if m.Attach != nil {
m.Attach(c)
}
ctx, cancel := context.WithCancel(context.Background())
m.mu.Lock()
if m.stop != nil {
m.stop()
}
m.client, m.saved, m.stop, m.username = c, saved, cancel, ""
m.mu.Unlock()
go c.Run(ctx)
go m.lookUpName(ctx, c)
}
func (m *Manager) lookUpName(ctx context.Context, c *hub.Client) {
var me struct {
Person struct {
Username string `json:"username"`
} `json:"person"`
}
if err := c.Call(ctx, "GET", "/api/v1/me", nil, &me); err != nil {
return
}
m.mu.Lock()
if m.client == c {
m.username = me.Person.Username
}
m.mu.Unlock()
}
type status struct {
SignedIn bool `json:"signedIn"`
Hub string `json:"hub"`
Username string `json:"username"`
SigningIn bool `json:"signingIn"`
Code string `json:"code"`
Page string `json:"page"`
Error string `json:"error"`
Choice string `json:"choice"` // the hub chosen for the next start, "" for the default
Default string `json:"default"` // the install's
Fixed bool `json:"fixed"` // chosen by $T5DE_HUB
Restart bool `json:"restart"` // the next start's hub isn't this run's
}
func (m *Manager) status() status {
m.mu.Lock()
defer m.mu.Unlock()
s := status{Hub: m.Hub, Default: m.Default, Fixed: m.Fixed, Choice: LoadChoice(m.ChoicePath)}
next := s.Choice
if next == "" {
next = m.Default
}
s.Restart = !m.Fixed && next != m.Hub
if m.saved != nil {
s.SignedIn, s.Username = true, m.username
}
if si := m.signin; si != nil {
s.SigningIn, s.Code, s.Page, s.Error = si.running, si.code, si.page, si.err
}
return s
}
// SignIn starts signing in to this run's hub; the code to enter shows in the status, and the browser opens the
// page.
func (m *Manager) SignIn() {
hubURL := m.Hub
ctx, cancel := context.WithCancel(context.Background())
si := &signin{running: true, cancel: cancel}
m.mu.Lock()
if m.signin != nil && m.signin.running {
m.signin.cancel()
}
m.signin = si
m.mu.Unlock()
go func() {
saved, err := hub.SignIn(ctx, hubURL, func(code, page string) {
m.mu.Lock()
si.code, si.page = code, page
m.mu.Unlock()
if m.OpenBrowser != nil {
m.OpenBrowser(page)
}
})
if err == nil {
err = saved.Save(m.Path)
}
m.mu.Lock()
si.running = false
if err != nil {
si.err = err.Error()
}
m.mu.Unlock()
if err != nil {
m.Log.Printf("hub: signing in: %v", err)
return
}
m.Log.Printf("hub: signed in to %s", saved.URL)
m.run(saved)
}()
}
// SignOut ends this computer's sign-in: the session on the hub, the saved sign-in, and the connection.
func (m *Manager) SignOut() {
m.mu.Lock()
c, stop := m.client, m.stop
m.client, m.saved, m.stop, m.username, m.signin, m.badges = nil, nil, nil, "", nil, nil
m.mu.Unlock()
if c != nil {
c.Call(context.Background(), "POST", "/api/auth/sign-out", map[string]any{}, nil)
}
if stop != nil {
stop()
}
if err := os.Remove(m.Path); err != nil && !errors.Is(err, os.ErrNotExist) {
m.Log.Printf("hub: signing out: %v", err)
}
m.Log.Printf("hub: signed out")
}
// Intercept implements proxy.Interceptor: it answers requests to Host.
func (m *Manager) Intercept(x *proxy.Exchange) error {
if !strings.EqualFold(x.URL.Hostname(), Host) {
return nil
}
method := x.Request.Method
switch {
case x.URL.Path == "/hub" && method == "GET":
case x.URL.Path == "/hub/signin" && method == "POST":
m.SignIn()
case x.URL.Path == "/hub/server" && method == "POST":
form, _ := url.ParseQuery(string(x.Body))
hub := form.Get("hub")
if hub != "" {
var err error
if hub, err = CheckHub(hub); err != nil {
b, _ := json.Marshal(map[string]string{"error": err.Error()})
x.Response = proxy.LocalResponse(400, "application/json", b)
return nil
}
}
if hub == m.Default {
hub = "" // the default stays the default, should it change
}
if err := SaveChoice(m.ChoicePath, hub); err != nil {
m.Log.Printf("hub: saving the choice: %v", err)
}
case x.URL.Path == "/hub/signout" && method == "POST":
m.SignOut()
case x.URL.Path == "/badges" && method == "POST":
var req struct {
IDs []int64 `json:"imvuIds"`
}
if json.Unmarshal(x.Body, &req) != nil || len(req.IDs) > 1000 {
x.Response = proxy.LocalResponse(400, "application/json", []byte(`{"error":"imvuIds: up to 1000 IMVU account IDs"}`))
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
badges := m.Badges(ctx, req.IDs)
if badges == nil {
badges = []int64{}
}
b, _ := json.Marshal(map[string]any{"badges": badges})
x.Response = proxy.LocalResponse(200, "application/json", b)
return nil
default:
x.Response = proxy.LocalResponse(404, "application/json", []byte(`{"error":"not found"}`))
return nil
}
b, _ := json.Marshal(m.status())
x.Response = proxy.LocalResponse(200, "application/json", b)
return nil
}