CI / test (push) Failing after 42s
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]>
268 lines
8.5 KiB
Go
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() }
|