CI / build (push) Successful in 29s
POST http://t5de-proxy.local/badges {imvuIds} -> {badges}: which IMVU accounts show the T5 badge, asked of the hub (POST /api/v1/badges, in batches of 200) and remembered for 10 minutes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
296 lines
7.7 KiB
Go
296 lines
7.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}
|
|
// POST /hub/signin starts signing in (hub=<URL> 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
|
|
}
|