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]>
This commit is contained in:
1 parent
171e8173af
commit
455dae1013
2 files changed
+130
-1
No files matched your search
@@ -5,6 +5,8 @@
|
|||||||
// GET /hub {signedIn, hub, username, signingIn, code, page, error}
|
// 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/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 /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
|
package control
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -16,6 +18,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||||
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||||
@@ -41,6 +44,70 @@ type Manager struct {
|
|||||||
username string
|
username string
|
||||||
lastHub string // the hub last signed in to, for signing in again after signing out
|
lastHub string // the hub last signed in to, for signing in again after signing out
|
||||||
signin *signin
|
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 {
|
type signin struct {
|
||||||
@@ -166,7 +233,7 @@ func (m *Manager) SignOut() {
|
|||||||
if m.saved != nil {
|
if m.saved != nil {
|
||||||
m.lastHub = m.saved.URL
|
m.lastHub = m.saved.URL
|
||||||
}
|
}
|
||||||
m.client, m.saved, m.stop, m.username, m.signin = nil, nil, nil, "", nil
|
m.client, m.saved, m.stop, m.username, m.signin, m.badges = nil, nil, nil, "", nil, nil
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
if c != nil {
|
if c != nil {
|
||||||
c.Call(context.Background(), "POST", "/api/auth/sign-out", map[string]any{}, nil)
|
c.Call(context.Background(), "POST", "/api/auth/sign-out", map[string]any{}, nil)
|
||||||
@@ -201,6 +268,23 @@ func (m *Manager) Intercept(x *proxy.Exchange) error {
|
|||||||
m.SignIn(hubURL)
|
m.SignIn(hubURL)
|
||||||
case x.URL.Path == "/hub/signout" && method == "POST":
|
case x.URL.Path == "/hub/signout" && method == "POST":
|
||||||
m.SignOut()
|
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:
|
default:
|
||||||
x.Response = proxy.LocalResponse(404, "application/json", []byte(`{"error":"not found"}`))
|
x.Response = proxy.LocalResponse(404, "application/json", []byte(`{"error":"not found"}`))
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import (
|
|||||||
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var badgeAsks atomic.Int32
|
||||||
|
|
||||||
// fakeHub does device sign-in (approved on the second poll), /me and sign-out.
|
// fakeHub does device sign-in (approved on the second poll), /me and sign-out.
|
||||||
func fakeHub(t *testing.T, signedOut *atomic.Bool) *httptest.Server {
|
func fakeHub(t *testing.T, signedOut *atomic.Bool) *httptest.Server {
|
||||||
var polls atomic.Int32
|
var polls atomic.Int32
|
||||||
@@ -38,6 +40,17 @@ func fakeHub(t *testing.T, signedOut *atomic.Bool) *httptest.Server {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
io.WriteString(w, `{"person": {"username": "erin"}}`)
|
io.WriteString(w, `{"person": {"username": "erin"}}`)
|
||||||
|
case "/api/v1/badges":
|
||||||
|
var req struct{ ImvuIds []int64 }
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
badgeAsks.Add(1)
|
||||||
|
var even []int64
|
||||||
|
for _, id := range req.ImvuIds {
|
||||||
|
if id%2 == 0 {
|
||||||
|
even = append(even, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"badges": even})
|
||||||
case "/api/auth/sign-out":
|
case "/api/auth/sign-out":
|
||||||
signedOut.Store(r.Header.Get("Authorization") == "Bearer session-token")
|
signedOut.Store(r.Header.Get("Authorization") == "Bearer session-token")
|
||||||
io.WriteString(w, `{"success": true}`)
|
io.WriteString(w, `{"success": true}`)
|
||||||
@@ -137,3 +150,35 @@ func TestSignInAndOut(t *testing.T) {
|
|||||||
t.Fatal("answered another host")
|
t.Fatal("answered another host")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBadges(t *testing.T) {
|
||||||
|
var signedOut atomic.Bool
|
||||||
|
srv := fakeHub(t, &signedOut)
|
||||||
|
m := &Manager{Path: filepath.Join(t.TempDir(), "hub.json"), DefaultHub: srv.URL, Log: log.New(io.Discard, "", 0)}
|
||||||
|
badges := func(body string) string {
|
||||||
|
t.Helper()
|
||||||
|
u, _ := url.Parse("http://" + Host + "/badges")
|
||||||
|
req, _ := http.NewRequest("POST", u.String(), strings.NewReader(body))
|
||||||
|
x := &proxy.Exchange{URL: u, Request: req, Body: []byte(body)}
|
||||||
|
m.Intercept(x)
|
||||||
|
b, _ := io.ReadAll(x.Response.Body)
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
// Signed out: nobody.
|
||||||
|
if got := badges(`{"imvuIds": [2, 3]}`); got != `{"badges":[]}` {
|
||||||
|
t.Fatalf("signed out: %s", got)
|
||||||
|
}
|
||||||
|
saved := &hub.Saved{URL: srv.URL, Token: "session-token"}
|
||||||
|
m.run(saved)
|
||||||
|
badgeAsks.Store(0)
|
||||||
|
if got := badges(`{"imvuIds": [2, 3, 4, 3]}`); got != `{"badges":[2,4]}` {
|
||||||
|
t.Fatalf("badges %s", got)
|
||||||
|
}
|
||||||
|
// Answers are remembered: only new IDs are asked about.
|
||||||
|
if got := badges(`{"imvuIds": [4, 3, 6]}`); got != `{"badges":[4,6]}` || badgeAsks.Load() != 2 {
|
||||||
|
t.Fatalf("badges %s after %d asks", got, badgeAsks.Load())
|
||||||
|
}
|
||||||
|
if got := badges(`{"imvuIds": "x"}`); !strings.Contains(got, "error") {
|
||||||
|
t.Fatalf("bad request: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user