Files
t5de-hub/internal/web/server.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

268 lines
8.5 KiB
Go

// Package web is the hub's website (server-rendered pages, htmx for the live parts) and its API for proxies.
package web
import (
"context"
"crypto/subtle"
"embed"
"errors"
"html/template"
"io/fs"
"log"
"net"
"net/http"
"strings"
"sync"
"time"
"git.t5de.dev/t5de/t5de-hub/internal/imvu"
"git.t5de.dev/t5de/t5de-hub/internal/realtime"
"git.t5de.dev/t5de/t5de-hub/internal/store"
"git.t5de.dev/t5de/t5de-hub/internal/token"
)
//go:embed templates/*.html
var templateFiles embed.FS
//go:embed static
var staticFiles embed.FS
// Audience is the hub's own API, in its access tokens.
const Audience = "t5de-hub"
// Server is the website and API.
type Server struct {
Store *store.Store
IMVU *imvu.Client
Keys *token.Keys
Realtime *realtime.Hub
PublicURL string // e.g. https://hub.t5de.dev, for links shown to proxies
Log *log.Logger
pages map[string]*template.Template
limiter *limiter
}
// Handler is the hub's HTTP handler.
func (s *Server) Handler() (http.Handler, error) {
if err := s.parseTemplates(); err != nil {
return nil, err
}
s.limiter = &limiter{hits: map[string][]time.Time{}}
mux := http.NewServeMux()
static, _ := fs.Sub(staticFiles, "static")
mux.Handle("GET /static/", http.StripPrefix("/static/", cacheStatic(http.FileServerFS(static))))
mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/static/favicon.svg", http.StatusMovedPermanently)
})
mux.HandleFunc("GET /{$}", s.page(s.home))
mux.HandleFunc("GET /signup", s.page(s.signupForm))
mux.HandleFunc("POST /signup", s.page(s.signup))
mux.HandleFunc("GET /signin", s.page(s.signinForm))
mux.HandleFunc("POST /signin", s.page(s.signin))
mux.HandleFunc("POST /signout", s.page(s.signout))
mux.HandleFunc("GET /online", s.page(s.online))
mux.HandleFunc("GET /online/list", s.page(s.onlineList))
mux.HandleFunc("GET /account", s.page(s.signedIn(s.account)))
mux.HandleFunc("POST /account/link", s.page(s.signedIn(s.startLink)))
mux.HandleFunc("POST /account/link/{imvu}/check", s.page(s.signedIn(s.checkLink)))
mux.HandleFunc("POST /account/link/{imvu}/cancel", s.page(s.signedIn(s.cancelLink)))
mux.HandleFunc("POST /account/links/{imvu}/settings", s.page(s.signedIn(s.linkSettings)))
mux.HandleFunc("POST /account/links/{imvu}/recheck", s.page(s.signedIn(s.recheckLink)))
mux.HandleFunc("POST /account/links/{imvu}/unlink", s.page(s.signedIn(s.unlink)))
mux.HandleFunc("POST /account/proxies/signout", s.page(s.signedIn(s.signOutProxies)))
mux.HandleFunc("GET /device", s.page(s.signedIn(s.deviceForm)))
mux.HandleFunc("POST /device", s.page(s.signedIn(s.approveDevice)))
mux.HandleFunc("GET /console", s.page(s.moderator(s.console)))
mux.HandleFunc("GET /console/online", s.page(s.moderator(s.consoleOnline)))
mux.HandleFunc("GET /console/people", s.page(s.moderator(s.consolePeople)))
mux.HandleFunc("GET /console/people/{id}", s.page(s.moderator(s.consolePerson)))
mux.HandleFunc("POST /console/people/{id}/cap", s.page(s.moderator(s.setCap)))
mux.HandleFunc("POST /console/people/{id}/ban", s.page(s.moderator(s.ban)))
mux.HandleFunc("POST /console/people/{id}/unban", s.page(s.moderator(s.unban)))
mux.HandleFunc("POST /console/people/{id}/role", s.page(s.admin(s.setRole)))
mux.HandleFunc("GET /.well-known/jwks.json", s.jwks)
mux.HandleFunc("POST /api/v1/device/code", s.deviceCode)
mux.HandleFunc("POST /api/v1/device/token", s.deviceToken)
mux.HandleFunc("POST /api/v1/token/refresh", s.refresh)
mux.HandleFunc("GET /api/v1/me", s.me)
mux.HandleFunc("GET /api/v1/realtime", s.realtime)
return securityHeaders(mux), nil
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' https: data:; style-src 'self'; script-src 'self'; "+
"connect-src 'self'; frame-ancestors 'none'; form-action 'self'; base-uri 'none'; object-src 'none'")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "same-origin")
h.Set("X-Frame-Options", "DENY")
next.ServeHTTP(w, r)
})
}
func cacheStatic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=3600")
next.ServeHTTP(w, r)
})
}
// request is one page request: who's asking, and what the page shows.
type request struct {
w http.ResponseWriter
r *http.Request
person *store.Person // nil when signed out
csrf string
token string // session token
flash string
}
type pageFunc func(q *request) error
// errStatus is an error with the HTTP status to answer with, shown to the person.
type errStatus struct {
status int
message string
}
func (e errStatus) Error() string { return e.message }
func fail(status int, message string) error { return errStatus{status, message} }
const sessionCookie = "t5de_session"
// page wraps a page handler: it finds the session, checks CSRF on POSTs, and shows errors.
func (s *Server) page(fn pageFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := &request{w: w, r: r, flash: r.URL.Query().Get("done")}
if c, err := r.Cookie(sessionCookie); err == nil {
p, csrf, err := s.Store.Session(r.Context(), c.Value)
if err == nil && !p.Banned() {
q.person, q.csrf, q.token = p, csrf, c.Value
}
}
if r.Method == http.MethodPost {
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
sent := r.Header.Get("X-CSRF-Token")
if sent == "" {
sent = r.PostFormValue("csrf")
}
// Signed in, a form must carry the session's token. Signed out (sign-in and sign-up), browsers' same-site
// checks apply: the form must come from the hub itself.
if q.person != nil && subtle.ConstantTimeCompare([]byte(sent), []byte(q.csrf)) != 1 ||
q.person == nil && !sameOrigin(r) {
s.showError(q, fail(http.StatusForbidden, "That form expired. Reload the page and try again."))
return
}
}
if err := fn(q); err != nil {
s.showError(q, err)
}
}
}
func sameOrigin(r *http.Request) bool {
if site := r.Header.Get("Sec-Fetch-Site"); site != "" {
return site == "same-origin" || site == "none"
}
origin := r.Header.Get("Origin")
return origin == "" || strings.TrimPrefix(strings.TrimPrefix(origin, "https://"), "http://") == r.Host
}
func (s *Server) signedIn(fn pageFunc) pageFunc {
return func(q *request) error {
if q.person == nil {
http.Redirect(q.w, q.r, "/signin?next="+q.r.URL.RequestURI(), http.StatusSeeOther)
return nil
}
return fn(q)
}
}
func (s *Server) moderator(fn pageFunc) pageFunc {
return s.signedIn(func(q *request) error {
if !q.person.IsModerator() {
return fail(http.StatusForbidden, "Only moderators can see this.")
}
return fn(q)
})
}
func (s *Server) admin(fn pageFunc) pageFunc {
return s.signedIn(func(q *request) error {
if !q.person.IsAdmin() {
return fail(http.StatusForbidden, "Only admins can do this.")
}
return fn(q)
})
}
func (s *Server) showError(q *request, err error) {
var e errStatus
if !errors.As(err, &e) {
s.Log.Printf("%s %s: %v", q.r.Method, q.r.URL.Path, err)
e = errStatus{http.StatusInternalServerError, "Something went wrong on our side. Try again in a moment."}
}
q.w.WriteHeader(e.status)
s.render(q, "error", map[string]any{"Message": e.message})
}
// redirect sends the browser on after a form, with a short note to show.
func (q *request) redirect(path, done string) error {
if done != "" {
sep := "?"
if strings.Contains(path, "?") {
sep = "&"
}
path += sep + "done=" + template.URLQueryEscaper(done)
}
http.Redirect(q.w, q.r, path, http.StatusSeeOther)
return nil
}
func clientIP(r *http.Request) string {
if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
return ip
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// limiter allows a number of attempts per key in a window (sign-ins, sign-ups, IMVU lookups).
type limiter struct {
mu sync.Mutex
hits map[string][]time.Time
}
func (l *limiter) allow(key string, n int, window time.Duration) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
recent := l.hits[key][:0]
for _, t := range l.hits[key] {
if now.Sub(t) < window {
recent = append(recent, t)
}
}
if len(recent) >= n {
l.hits[key] = recent
return false
}
l.hits[key] = append(recent, now)
if len(l.hits) > 100000 { // forget everything rather than grow without end
l.hits = map[string][]time.Time{}
}
return true
}
func (s *Server) ctx(q *request) context.Context { return q.r.Context() }