// 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} // POST /hub/signin starts signing in (hub= optional): opens the hub's page for the code in the browser // POST /hub/signout signs this computer out // 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 DefaultHub string 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 lastHub string // the hub last signed in to, for signing in again after signing out 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 } 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"` } func (m *Manager) status() status { m.mu.Lock() defer m.mu.Unlock() s := status{Hub: m.DefaultHub} if m.lastHub != "" { s.Hub = m.lastHub } if m.saved != nil { s.SignedIn, s.Hub, s.Username = true, m.saved.URL, 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 a hub; the code to enter shows in the status, and the browser opens the page. func (m *Manager) SignIn(hubURL string) { if hubURL == "" { hubURL = m.status().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 if m.saved != nil { m.lastHub = m.saved.URL } 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": form, _ := url.ParseQuery(string(x.Body)) hubURL := strings.TrimSuffix(form.Get("hub"), "/") if hubURL != "" { u, err := url.Parse(hubURL) if err != nil || (u.Scheme != "https" && u.Hostname() != "localhost" && u.Hostname() != "127.0.0.1") { x.Response = proxy.LocalResponse(400, "application/json", []byte(`{"error":"the hub needs an https address"}`)) return nil } } m.SignIn(hubURL) 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 }