// 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=, 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 }