Files
t5de-hub/internal/web/api.go
T
dhkatzandClaude Opus 5.5 b9dc3f0ec1
CI / test (push) Failing after 42s
Hub: accounts, IMVU linking by tagline, proxy sign-in, presence, console
People sign up on the website and link IMVU accounts by putting a code in the account's tagline, which the
hub reads from IMVU's public API (the most recent proof wins; link caps per person). IMVU's age verification
or Access Pass make an account adult. T5DE's proxy signs in with a device code and keeps a WebSocket open,
reporting the IMVU account in use and the community products it wears; /online shows those who opted in.
Moderators get a console: who's online, people, link caps, bans, roles, and the audit log. Tests run the
website and API against an embedded Postgres and a fake IMVU API.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 03:45:52 -07:00

217 lines
6.3 KiB
Go

package web
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"git.t5de.dev/t5de/t5de-hub/internal/store"
"git.t5de.dev/t5de/t5de-hub/internal/token"
)
// The proxy signs in like a TV app (OAuth 2.0 device authorization, RFC 8628): it asks for a code, the person
// enters it on the website, and the proxy gets tokens: a short-lived access token (Ed25519 JWT) for the API, and
// a refresh token it trades for new ones.
const (
deviceLifetime = 10 * time.Minute
accessLifetime = 15 * time.Minute
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func apiError(w http.ResponseWriter, status int, code, description string) {
writeJSON(w, status, map[string]string{"error": code, "error_description": description})
}
// userCode is what a person types: 8 letters, no look-alikes, shown as XXXX-XXXX.
func userCode() string {
const alphabet = "BCDFGHJKLMNPQRSTVWXZ"
b := make([]byte, 8)
rand.Read(b)
for i := range b {
b[i] = alphabet[int(b[i])%len(alphabet)]
}
return string(b[:4]) + "-" + string(b[4:])
}
func normalizeUserCode(code string) string {
code = strings.ToUpper(strings.Map(func(r rune) rune {
if r == ' ' || r == '-' {
return -1
}
return r
}, code))
if len(code) == 8 {
code = code[:4] + "-" + code[4:]
}
return code
}
func (s *Server) deviceCode(w http.ResponseWriter, r *http.Request) {
if !s.limiter.allow("device:"+clientIP(r), 20, time.Hour) {
apiError(w, http.StatusTooManyRequests, "slow_down", "too many sign-ins from here")
return
}
device := token.Random(32)
var code string
for range 5 {
code = userCode()
err := s.Store.CreateDeviceCode(r.Context(), device, code, time.Now().Add(deviceLifetime))
if err == nil {
break
}
if !errors.Is(err, store.ErrTaken) {
s.Log.Printf("device code: %v", err)
apiError(w, http.StatusInternalServerError, "server_error", "")
return
}
}
writeJSON(w, http.StatusOK, map[string]any{
"device_code": device, "user_code": code, "expires_in": int(deviceLifetime.Seconds()), "interval": 3,
"verification_uri": s.PublicURL + "/device",
"verification_uri_complete": s.PublicURL + "/device?code=" + code,
})
}
func (s *Server) issue(ctx context.Context, w http.ResponseWriter, personID int64, refresh string) {
access, err := s.Keys.Issue("person:"+strconv.FormatInt(personID, 10), Audience, "proxy", accessLifetime)
if err != nil {
apiError(w, http.StatusInternalServerError, "server_error", "")
return
}
writeJSON(w, http.StatusOK, map[string]any{"access_token": access, "token_type": "Bearer",
"expires_in": int(accessLifetime.Seconds()), "refresh_token": refresh})
}
func (s *Server) deviceToken(w http.ResponseWriter, r *http.Request) {
status, personID, err := s.Store.ConsumeDeviceCode(r.Context(), r.PostFormValue("device_code"))
if err != nil {
s.Log.Printf("device token: %v", err)
apiError(w, http.StatusInternalServerError, "server_error", "")
return
}
switch status {
case store.DevicePending:
apiError(w, http.StatusBadRequest, "authorization_pending", "waiting for the code to be entered")
return
case store.DeviceGone:
apiError(w, http.StatusBadRequest, "expired_token", "the code expired or was used")
return
}
p, err := s.Store.PersonByID(r.Context(), personID)
if err != nil || p.Banned() {
apiError(w, http.StatusForbidden, "access_denied", "")
return
}
refresh := token.Random(32)
if err := s.Store.CreateRefreshToken(r.Context(), refresh, personID); err != nil {
apiError(w, http.StatusInternalServerError, "server_error", "")
return
}
s.issue(r.Context(), w, personID, refresh)
}
func (s *Server) refresh(w http.ResponseWriter, r *http.Request) {
replacement := token.Random(32)
personID, err := s.Store.RotateRefreshToken(r.Context(), r.PostFormValue("refresh_token"), replacement)
if errors.Is(err, store.ErrNotFound) {
apiError(w, http.StatusUnauthorized, "invalid_grant", "signed out")
return
}
if err != nil {
apiError(w, http.StatusInternalServerError, "server_error", "")
return
}
p, err := s.Store.PersonByID(r.Context(), personID)
if err != nil || p.Banned() {
apiError(w, http.StatusForbidden, "access_denied", "")
return
}
s.issue(r.Context(), w, personID, replacement)
}
// bearer is the person an API request's access token is for.
func (s *Server) bearer(r *http.Request) (*store.Person, error) {
auth := r.Header.Get("Authorization")
tok, ok := strings.CutPrefix(auth, "Bearer ")
if !ok {
return nil, errors.New("no token")
}
c, err := s.Keys.Check(tok, Audience)
if err != nil {
return nil, err
}
id, err := strconv.ParseInt(strings.TrimPrefix(c.Subject, "person:"), 10, 64)
if err != nil {
return nil, err
}
p, err := s.Store.PersonByID(r.Context(), id)
if err != nil || p.Banned() {
return nil, errors.New("no such person")
}
return p, nil
}
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
p, err := s.bearer(r)
if err != nil {
apiError(w, http.StatusUnauthorized, "invalid_token", "")
return
}
links, err := s.Store.Links(r.Context(), p.ID)
if err != nil {
apiError(w, http.StatusInternalServerError, "server_error", "")
return
}
type link struct {
IMVUID int64 `json:"imvu_id"`
Username string `json:"username"`
Adult bool `json:"adult"`
}
out := []link{}
for _, l := range links {
out = append(out, link{l.IMVUID, l.Username, l.Adult()})
}
writeJSON(w, http.StatusOK, map[string]any{"username": p.Username, "links": out})
}
func (s *Server) realtime(w http.ResponseWriter, r *http.Request) {
p, err := s.bearer(r)
if err != nil {
apiError(w, http.StatusUnauthorized, "invalid_token", "")
return
}
s.Realtime.Serve(w, r, p.ID)
}
func (s *Server) jwks(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=300")
w.Write(s.Keys.JWKS())
}
// LinkOwner implements realtime.Owners.
type Owners struct{ Store *store.Store }
func (o Owners) LinkOwner(ctx context.Context, imvuID int64) (int64, bool, error) {
l, err := o.Store.LinkByIMVUID(ctx, imvuID)
if errors.Is(err, store.ErrNotFound) {
return 0, false, nil
}
if err != nil {
return 0, false, err
}
return l.PersonID, true, nil
}