// 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() }