Hub: accounts, IMVU linking by tagline, proxy sign-in, presence, console
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]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-29 03:45:52 -07:00
1 parent 54e569bea3
commit b9dc3f0ec1
36 files changed
+3548 -5

No files matched your search

+278
View File
@@ -0,0 +1,278 @@
package web
import (
"crypto/rand"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"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"
)
func (s *Server) home(q *request) error {
data := map[string]any{"OnlineCount": len(s.visibleOnline(q))}
if q.person != nil {
links, err := s.Store.Links(s.ctx(q), q.person.ID)
if err != nil {
return err
}
data["Links"] = links
}
s.render(q, "home", data)
return nil
}
// linkView is a linked account on the account page.
type linkView struct {
store.Link
Online bool
Outfit []realtime.Item
}
func (s *Server) account(q *request) error {
ctx := s.ctx(q)
links, err := s.Store.Links(ctx, q.person.ID)
if err != nil {
return err
}
challenges, err := s.Store.Challenges(ctx, q.person.ID)
if err != nil {
return err
}
signins, err := s.Store.Signins(ctx, q.person.ID)
if err != nil {
return err
}
online := map[int64]realtime.Online{}
for _, o := range s.Realtime.Online() {
online[o.IMVUID] = o
}
views := make([]linkView, len(links))
for i, l := range links {
o, ok := online[l.IMVUID]
views[i] = linkView{Link: l, Online: ok, Outfit: o.Outfit}
}
s.render(q, "account", map[string]any{
"Links": views, "Challenges": challenges, "Signins": signins,
"CanLink": len(links) < q.person.LinkCap, "Error": q.r.URL.Query().Get("error"),
})
return nil
}
// linkCode is what goes in the tagline: "t5de-" and eight letters and digits (no look-alikes).
func linkCode() string {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
b := make([]byte, 8)
rand.Read(b)
for i := range b {
b[i] = alphabet[int(b[i])%len(alphabet)]
}
return "t5de-" + string(b[:4]) + "-" + string(b[4:])
}
func (s *Server) startLink(q *request) error {
username := strings.TrimSpace(q.r.PostFormValue("username"))
if !s.limiter.allow("imvu:"+strconv.FormatInt(q.person.ID, 10), 20, time.Hour) {
return q.redirect("/account?error="+queryEscape("Too many lookups. Try again in a while."), "")
}
u, err := s.IMVU.ByUsername(s.ctx(q), username)
if errors.Is(err, imvu.ErrNoSuchUser) {
return q.redirect("/account?error="+queryEscape(fmt.Sprintf("IMVU has no account named %q.", username)), "")
}
if err != nil {
s.Log.Printf("looking up %q on IMVU: %v", username, err)
return q.redirect("/account?error="+queryEscape("IMVU didn't answer. Try again in a moment."), "")
}
if l, err := s.Store.LinkByIMVUID(s.ctx(q), u.ID); err == nil && l.PersonID == q.person.ID {
return q.redirect("/account", u.Username+" is already linked.")
}
if err := s.Store.SetChallenge(s.ctx(q), q.person.ID, u.ID, u.Username, linkCode()); err != nil {
return err
}
return q.redirect("/account#link-"+strconv.FormatInt(u.ID, 10), "")
}
func imvuIDParam(q *request) (int64, error) {
id, err := strconv.ParseInt(q.r.PathValue("imvu"), 10, 64)
if err != nil || id <= 0 {
return 0, fail(http.StatusNotFound, "No such account.")
}
return id, nil
}
// checkLink reads the account's tagline from IMVU and links it if the code is there.
func (s *Server) checkLink(q *request) error {
ctx := s.ctx(q)
id, err := imvuIDParam(q)
if err != nil {
return err
}
c, err := s.Store.Challenge(ctx, q.person.ID, id)
if errors.Is(err, store.ErrNotFound) {
return q.redirect("/account?error="+queryEscape("That code expired. Start again."), "")
}
if err != nil {
return err
}
if !s.limiter.allow("imvu:"+strconv.FormatInt(q.person.ID, 10), 20, time.Hour) {
return q.redirect("/account?error="+queryEscape("Too many checks. Try again in a while."), "")
}
u, err := s.IMVU.ByID(ctx, id)
if err != nil {
s.Log.Printf("checking IMVU account %d: %v", id, err)
return q.redirect("/account?error="+queryEscape("IMVU didn't answer. Try again in a moment."), "")
}
if !strings.Contains(strings.ToLower(u.Tagline), strings.ToLower(c.Code)) {
return q.redirect("/account?error="+queryEscape(fmt.Sprintf("%s's tagline doesn't have the code yet. IMVU can take a minute to show a new tagline.", u.Username))+"#link-"+strconv.FormatInt(id, 10), "")
}
moved, err := s.Store.LinkAccount(ctx, q.person.ID, u.ID, u.Username, token.Random(16), u.AgeVerified, u.AccessPass)
if errors.Is(err, store.ErrLinkCap) {
return q.redirect("/account?error="+queryEscape(fmt.Sprintf("You can link up to %d IMVU accounts. Ask a moderator if you need more.", q.person.LinkCap)), "")
}
if err != nil {
return err
}
detail := map[string]any{"imvu": u.Username}
if moved != 0 {
detail["moved_from"] = moved
}
s.Store.Audit(ctx, q.person.ID, "link.add", strconv.FormatInt(u.ID, 10), detail)
return q.redirect("/account", fmt.Sprintf("Linked %s. You can take the code out of your tagline now.", u.Username))
}
func (s *Server) cancelLink(q *request) error {
id, err := imvuIDParam(q)
if err != nil {
return err
}
if err := s.Store.DeleteChallenge(s.ctx(q), q.person.ID, id); err != nil {
return err
}
return q.redirect("/account", "")
}
func (s *Server) ownLink(q *request) (*store.Link, error) {
id, err := imvuIDParam(q)
if err != nil {
return nil, err
}
l, err := s.Store.LinkByIMVUID(s.ctx(q), id)
if errors.Is(err, store.ErrNotFound) || err == nil && l.PersonID != q.person.ID {
return nil, fail(http.StatusNotFound, "That account isn't linked to you.")
}
return l, err
}
func (s *Server) linkSettings(q *request) error {
l, err := s.ownLink(q)
if err != nil {
return err
}
badge, presence := q.r.PostFormValue("badge") == "on", q.r.PostFormValue("presence") == "on"
if err := s.Store.SetLinkSettings(s.ctx(q), q.person.ID, l.IMVUID, badge, presence); err != nil {
return err
}
return q.redirect("/account", "Saved settings for "+l.Username+".")
}
// recheckLink reads the account's age status from IMVU again.
func (s *Server) recheckLink(q *request) error {
l, err := s.ownLink(q)
if err != nil {
return err
}
if !s.limiter.allow("imvu:"+strconv.FormatInt(q.person.ID, 10), 20, time.Hour) {
return q.redirect("/account?error="+queryEscape("Too many checks. Try again in a while."), "")
}
u, err := s.IMVU.ByID(s.ctx(q), l.IMVUID)
if err != nil {
return q.redirect("/account?error="+queryEscape("IMVU didn't answer. Try again in a moment."), "")
}
if err := s.Store.UpdateAge(s.ctx(q), l.IMVUID, u.Username, u.AgeVerified, u.AccessPass); err != nil {
return err
}
return q.redirect("/account", "Checked "+u.Username+" with IMVU.")
}
func (s *Server) unlink(q *request) error {
l, err := s.ownLink(q)
if err != nil {
return err
}
if err := s.Store.Unlink(s.ctx(q), q.person.ID, l.IMVUID); err != nil {
return err
}
s.Store.Audit(s.ctx(q), q.person.ID, "link.remove", strconv.FormatInt(l.IMVUID, 10), map[string]any{"imvu": l.Username})
return q.redirect("/account", "Unlinked "+l.Username+".")
}
func (s *Server) signOutProxies(q *request) error {
if err := s.Store.SignOutProxies(s.ctx(q), q.person.ID); err != nil {
return err
}
s.Realtime.Disconnect(q.person.ID)
return q.redirect("/account", "Signed out of T5DE on all your computers.")
}
func (s *Server) deviceForm(q *request) error {
s.render(q, "device", map[string]any{"Code": strings.ToUpper(q.r.URL.Query().Get("code"))})
return nil
}
func (s *Server) approveDevice(q *request) error {
code := normalizeUserCode(q.r.PostFormValue("code"))
err := s.Store.ApproveDeviceCode(s.ctx(q), code, q.person.ID)
if errors.Is(err, store.ErrNotFound) {
q.w.WriteHeader(http.StatusBadRequest)
s.render(q, "device", map[string]any{"Code": code, "Error": "That code isn't right, or it expired. Check the code T5DE shows."})
return nil
}
if err != nil {
return err
}
s.Store.Audit(s.ctx(q), q.person.ID, "proxy.signin", "", nil)
s.render(q, "device", map[string]any{"Approved": true})
return nil
}
// visibleOnline is who the viewer may see online in T5DE: accounts that opted in to presence (moderators see all).
func (s *Server) visibleOnline(q *request) []onlineView {
var out []onlineView
for _, o := range s.Realtime.Online() {
l, err := s.Store.LinkByIMVUID(s.ctx(q), o.IMVUID)
if err != nil {
continue
}
mod := q.person != nil && q.person.IsModerator()
if !l.ShowPresence && !mod {
continue
}
out = append(out, onlineView{Link: *l, Online: o, Hidden: !l.ShowPresence})
}
return out
}
type onlineView struct {
Link store.Link
Online realtime.Online
Hidden bool // shown to a moderator only
}
func (s *Server) online(q *request) error {
s.render(q, "online", map[string]any{"Online": s.visibleOnline(q)})
return nil
}
func (s *Server) onlineList(q *request) error {
s.fragment(q, "online", "online-list", map[string]any{"Online": s.visibleOnline(q)})
return nil
}
func queryEscape(s string) string { return url.QueryEscape(s) }
+216
View File
@@ -0,0 +1,216 @@
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
}
+172
View File
@@ -0,0 +1,172 @@
package web
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"unicode/utf8"
"golang.org/x/crypto/argon2"
"git.t5de.dev/t5de/t5de-hub/internal/store"
"git.t5de.dev/t5de/t5de-hub/internal/token"
)
// Passwords are hashed with Argon2id (OWASP's minimum: 19 MiB, 2 passes).
const (
argonTime = 2
argonMemory = 19 * 1024
argonThreads = 1
argonKeyLen = 32
)
func hashPassword(password string) string {
salt := make([]byte, 16)
rand.Read(salt)
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
enc := base64.RawStdEncoding.EncodeToString
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", argonMemory, argonTime, argonThreads, enc(salt), enc(key))
}
func checkPassword(hash, password string) bool {
parts := strings.Split(hash, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false
}
var m, t uint32
var p uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
return false
}
salt, err1 := base64.RawStdEncoding.DecodeString(parts[4])
want, err2 := base64.RawStdEncoding.DecodeString(parts[5])
if err1 != nil || err2 != nil {
return false
}
got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1
}
// dummyHash is checked against for unknown usernames, so sign-in takes as long either way.
var dummyHash = hashPassword("not a real password")
var usernamePattern = regexp.MustCompile(`^[A-Za-z0-9_]{3,32}$`)
func (s *Server) signupForm(q *request) error {
if q.person != nil {
return q.redirect("/account", "")
}
s.render(q, "signup", nil)
return nil
}
func (s *Server) signup(q *request) error {
username := strings.TrimSpace(q.r.PostFormValue("username"))
password := q.r.PostFormValue("password")
problem := ""
switch {
case !usernamePattern.MatchString(username):
problem = "Usernames are 3 to 32 letters, digits or underscores."
case utf8.RuneCountInString(password) < 10:
problem = "Use a password of at least 10 characters."
case len(password) > 256:
problem = "That password is too long."
case password != q.r.PostFormValue("password2"):
problem = "The passwords don't match."
case !s.limiter.allow("signup:"+clientIP(q.r), 5, time.Hour):
problem = "Too many new accounts from here. Try again later."
}
if problem != "" {
q.w.WriteHeader(http.StatusBadRequest)
s.render(q, "signup", map[string]any{"Error": problem, "Username": username})
return nil
}
p, err := s.Store.CreatePerson(s.ctx(q), username, hashPassword(password))
if errors.Is(err, store.ErrTaken) {
q.w.WriteHeader(http.StatusBadRequest)
s.render(q, "signup", map[string]any{"Error": "That username is taken.", "Username": username})
return nil
}
if err != nil {
return err
}
s.Store.Audit(s.ctx(q), p.ID, "account.create", p.Username, nil)
if err := s.startSession(q, p.ID); err != nil {
return err
}
return q.redirect("/account", "Welcome to T5DE! Link your IMVU account to get started.")
}
func (s *Server) signinForm(q *request) error {
if q.person != nil {
return q.redirect("/account", "")
}
s.render(q, "signin", map[string]any{"Next": safeNext(q.r.URL.Query().Get("next"))})
return nil
}
func (s *Server) signin(q *request) error {
username := strings.TrimSpace(q.r.PostFormValue("username"))
password := q.r.PostFormValue("password")
next := safeNext(q.r.PostFormValue("next"))
again := func(problem string) error {
q.w.WriteHeader(http.StatusBadRequest)
s.render(q, "signin", map[string]any{"Error": problem, "Username": username, "Next": next})
return nil
}
if !s.limiter.allow("signin:"+clientIP(q.r), 20, 15*time.Minute) || !s.limiter.allow("signin-user:"+strings.ToLower(username), 10, 15*time.Minute) {
return again("Too many tries. Wait a few minutes and try again.")
}
p, err := s.Store.PersonByUsername(s.ctx(q), username)
if errors.Is(err, store.ErrNotFound) {
checkPassword(dummyHash, password)
return again("Wrong username or password.")
}
if err != nil {
return err
}
if !checkPassword(p.PasswordHash, password) {
return again("Wrong username or password.")
}
if p.Banned() {
return again("This account is banned. " + p.BanReason)
}
if err := s.startSession(q, p.ID); err != nil {
return err
}
return q.redirect(next, "")
}
// safeNext keeps redirects after signing in on the hub.
func safeNext(next string) string {
if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") {
return "/account"
}
return next
}
const sessionLifetime = 30 * 24 * time.Hour
func (s *Server) startSession(q *request, personID int64) error {
tok, csrf := token.Random(32), token.Random(24)
if err := s.Store.CreateSession(s.ctx(q), tok, personID, csrf, time.Now().Add(sessionLifetime)); err != nil {
return err
}
http.SetCookie(q.w, &http.Cookie{Name: sessionCookie, Value: tok, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode,
Secure: strings.HasPrefix(s.PublicURL, "https://"), MaxAge: int(sessionLifetime.Seconds())})
return nil
}
func (s *Server) signout(q *request) error {
if q.token != "" {
s.Store.DeleteSession(s.ctx(q), q.token)
}
http.SetCookie(q.w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
return q.redirect("/", "Signed out.")
}
+141
View File
@@ -0,0 +1,141 @@
package web
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"git.t5de.dev/t5de/t5de-hub/internal/store"
)
// The moderation console: counts, who's online, people (search, link caps, bans, roles), and the audit log.
func (s *Server) console(q *request) error {
counts, err := s.Store.Counts(s.ctx(q))
if err != nil {
return err
}
log, err := s.Store.AuditLog(s.ctx(q), 50)
if err != nil {
return err
}
s.render(q, "console", map[string]any{"Counts": counts, "Connections": s.Realtime.Connections(),
"Online": s.visibleOnline(q), "Audit": log})
return nil
}
func (s *Server) consoleOnline(q *request) error {
s.fragment(q, "console", "console-online", map[string]any{"Online": s.visibleOnline(q), "Connections": s.Realtime.Connections()})
return nil
}
func (s *Server) consolePeople(q *request) error {
search := strings.TrimSpace(q.r.URL.Query().Get("q"))
people, err := s.Store.People(s.ctx(q), search, 100)
if err != nil {
return err
}
s.render(q, "console_people", map[string]any{"People": people, "Search": search})
return nil
}
func (s *Server) personParam(q *request) (*store.Person, error) {
id, err := strconv.ParseInt(q.r.PathValue("id"), 10, 64)
if err != nil {
return nil, fail(http.StatusNotFound, "No such person.")
}
p, err := s.Store.PersonByID(s.ctx(q), id)
if errors.Is(err, store.ErrNotFound) {
return nil, fail(http.StatusNotFound, "No such person.")
}
return p, err
}
func (s *Server) consolePerson(q *request) error {
p, err := s.personParam(q)
if err != nil {
return err
}
links, err := s.Store.Links(s.ctx(q), p.ID)
if err != nil {
return err
}
signins, err := s.Store.Signins(s.ctx(q), p.ID)
if err != nil {
return err
}
s.render(q, "console_person", map[string]any{"Subject": p, "Links": links, "Signins": signins})
return nil
}
func (s *Server) setCap(q *request) error {
p, err := s.personParam(q)
if err != nil {
return err
}
cap, err := strconv.Atoi(q.r.PostFormValue("cap"))
if err != nil || cap < 0 || cap > 1000 {
return fail(http.StatusBadRequest, "The link cap is a number from 0 to 1000.")
}
if err := s.Store.SetLinkCap(s.ctx(q), p.ID, cap); err != nil {
return err
}
s.Store.Audit(s.ctx(q), q.person.ID, "person.link_cap", p.Username, map[string]any{"from": p.LinkCap, "to": cap})
return q.redirect(fmt.Sprintf("/console/people/%d", p.ID), fmt.Sprintf("%s can link %d accounts.", p.Username, cap))
}
func (s *Server) ban(q *request) error {
p, err := s.personParam(q)
if err != nil {
return err
}
if p.ID == q.person.ID {
return fail(http.StatusBadRequest, "You can't ban yourself.")
}
if p.IsModerator() && !q.person.IsAdmin() {
return fail(http.StatusForbidden, "Only admins can ban moderators.")
}
reason := strings.TrimSpace(q.r.PostFormValue("reason"))
if reason == "" || len(reason) > 500 {
return fail(http.StatusBadRequest, "Give a reason (up to 500 characters); the person sees it.")
}
if err := s.Store.SetBanned(s.ctx(q), p.ID, true, reason); err != nil {
return err
}
s.Realtime.Disconnect(p.ID)
s.Store.Audit(s.ctx(q), q.person.ID, "person.ban", p.Username, map[string]any{"reason": reason})
return q.redirect(fmt.Sprintf("/console/people/%d", p.ID), "Banned "+p.Username+".")
}
func (s *Server) unban(q *request) error {
p, err := s.personParam(q)
if err != nil {
return err
}
if err := s.Store.SetBanned(s.ctx(q), p.ID, false, ""); err != nil {
return err
}
s.Store.Audit(s.ctx(q), q.person.ID, "person.unban", p.Username, nil)
return q.redirect(fmt.Sprintf("/console/people/%d", p.ID), "Unbanned "+p.Username+".")
}
func (s *Server) setRole(q *request) error {
p, err := s.personParam(q)
if err != nil {
return err
}
role := q.r.PostFormValue("role")
if role != "user" && role != "moderator" && role != "admin" {
return fail(http.StatusBadRequest, "No such role.")
}
if p.ID == q.person.ID {
return fail(http.StatusBadRequest, "Another admin has to change your role.")
}
if err := s.Store.SetRole(s.ctx(q), p.ID, role); err != nil {
return err
}
s.Store.Audit(s.ctx(q), q.person.ID, "person.role", p.Username, map[string]any{"from": p.Role, "to": role})
return q.redirect(fmt.Sprintf("/console/people/%d", p.ID), p.Username+" is now "+role+".")
}
+82
View File
@@ -0,0 +1,82 @@
package web
import (
"bytes"
"fmt"
"html/template"
"net/http"
"strings"
"time"
)
// pageNames are the templates with a full page each; "layout" wraps them. Fragments (for htmx) are defined inside
// page files and rendered on their own.
var pageNames = []string{"home", "signin", "signup", "account", "device", "online", "console", "console_people",
"console_person", "error"}
var funcs = template.FuncMap{
"ago": func(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%d min ago", int(d.Minutes()))
case d < 48*time.Hour:
return fmt.Sprintf("%d h ago", int(d.Hours()))
}
return t.Format("2 Jan 2006")
},
"date": func(t time.Time) string { return t.Format("2 Jan 2006 15:04") },
"upper": strings.ToUpper,
}
func (s *Server) parseTemplates() error {
s.pages = map[string]*template.Template{}
for _, name := range pageNames {
t, err := template.New("layout.html").Funcs(funcs).ParseFS(templateFiles, "templates/layout.html", "templates/"+name+".html")
if err != nil {
return err
}
s.pages[name] = t
}
return nil
}
// render shows a page in the layout. data is the page's own values; the layout gets the person and CSRF token.
func (s *Server) render(q *request, name string, data map[string]any) {
if data == nil {
data = map[string]any{}
}
data["Person"] = q.person
data["CSRF"] = q.csrf
data["Flash"] = q.flash
data["Page"] = name
var buf bytes.Buffer
if err := s.pages[name].ExecuteTemplate(&buf, "layout.html", data); err != nil {
s.Log.Printf("rendering %s: %v", name, err)
http.Error(q.w, "Something went wrong on our side.", http.StatusInternalServerError)
return
}
q.w.Header().Set("Content-Type", "text/html; charset=utf-8")
q.w.Header().Set("Cache-Control", "no-store")
buf.WriteTo(q.w)
}
// fragment shows one template from a page's file, without the layout (htmx swaps it in).
func (s *Server) fragment(q *request, page, name string, data map[string]any) {
if data == nil {
data = map[string]any{}
}
data["Person"] = q.person
data["CSRF"] = q.csrf
var buf bytes.Buffer
if err := s.pages[page].ExecuteTemplate(&buf, name, data); err != nil {
s.Log.Printf("rendering %s/%s: %v", page, name, err)
http.Error(q.w, "Something went wrong on our side.", http.StatusInternalServerError)
return
}
q.w.Header().Set("Content-Type", "text/html; charset=utf-8")
q.w.Header().Set("Cache-Control", "no-store")
buf.WriteTo(q.w)
}
+267
View File
@@ -0,0 +1,267 @@
// 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() }
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ffe795"/><stop offset="1" stop-color="#ffd200"/></linearGradient></defs><rect width="64" height="64" rx="14" fill="url(#g)"/><text x="32" y="43" font-family="Segoe UI, Arial, sans-serif" font-size="27" font-weight="800" text-anchor="middle" fill="#000">T5</text></svg>

After

Width:  |  Height:  |  Size: 416 B

+1
View File
@@ -0,0 +1 @@
var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toStrinLine truncated
+173
View File
@@ -0,0 +1,173 @@
/* T5DE Hub: IMVU Classic's dialog palette (dark grays, gold buttons with black text), always dark. */
:root {
--bg: #262626;
--panel: #383838;
--panel-2: #303030;
--line: #4a4a4a;
--text: #ededed;
--muted: #a8a8a8;
--gold: #ffd200;
--gold-light: #ffe795;
--green: #6fd36f;
--red: #ff7b72;
--blue: #7cc4ff;
--radius: 8px;
color-scheme: dark;
}
* { box-sizing: border-box; }
html { background: var(--bg); }
body {
margin: 0;
color: var(--text);
font: 15px/1.5 "Segoe UI", system-ui, -apple-system, Roboto, "Helvetica Neue", Arial, sans-serif;
}
a { color: var(--gold); text-decoration: none; }
a:hover { text-decoration: underline; }
.wrap { max-width: 960px; margin: 0 auto; padding: 0 20px; }
/* Header */
.top {
background: linear-gradient(#3a3a3a, #2c2c2c);
border-bottom: 1px solid #000;
box-shadow: 0 1px 0 #4a4a4a inset, 0 2px 8px rgba(0, 0, 0, .4);
}
.top .wrap { display: flex; align-items: center; gap: 24px; height: 56px; }
.brand { color: var(--text); font-weight: 600; font-size: 17px; display: flex; align-items: center; gap: 8px; }
.brand:hover { text-decoration: none; }
.logo {
background: linear-gradient(var(--gold-light), var(--gold));
color: #000; font-weight: 800; letter-spacing: .5px;
padding: 2px 8px; border-radius: 5px; font-size: 15px;
}
nav { display: flex; gap: 4px; align-items: center; flex: 1; }
nav a { color: var(--text); padding: 6px 12px; border-radius: 6px; }
nav a:hover, nav a.here { background: #4a4a4a; text-decoration: none; }
nav .right { margin-left: auto; display: flex; gap: 8px; align-items: center; }
nav form { margin: 0; }
main { padding-top: 28px; padding-bottom: 40px; min-height: 70vh; }
footer { padding: 24px 20px 40px; font-size: 13px; }
h1 { font-size: 26px; margin: 0 0 6px; font-weight: 600; }
h2 { font-size: 18px; margin: 0 0 12px; font-weight: 600; }
p.lead { color: var(--muted); margin: 0 0 24px; font-size: 16px; }
.muted { color: var(--muted); }
.small { font-size: 13px; }
/* Panels */
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, .25);
}
.panel.narrow { max-width: 420px; margin-left: auto; margin-right: auto; }
.panel h2 .count { color: var(--muted); font-weight: 400; font-size: 14px; margin-left: 6px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 20px; }
.stat { font-size: 30px; font-weight: 600; }
/* Buttons and forms */
.button, button {
display: inline-block;
font: inherit; font-weight: 600; font-size: 14px;
padding: 7px 16px;
border-radius: 6px;
border: 1px solid #b38f00;
background: linear-gradient(var(--gold-light), var(--gold));
color: #000;
cursor: pointer;
}
.button:hover, button:hover { filter: brightness(1.06); text-decoration: none; }
button.secondary, .button.secondary {
background: linear-gradient(#616161, #3e3e3e);
border-color: #2a2a2a;
color: var(--text);
}
button.danger { background: linear-gradient(#b8443c, #8c2a24); border-color: #5c1a16; color: #fff; }
button.link { background: none; border: none; color: var(--gold); padding: 0; font-weight: 400; }
label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 4px; }
input[type=text], input[type=password], input[type=number], input[type=search], textarea, select {
width: 100%;
font: inherit;
padding: 8px 10px;
color: var(--text);
background: #262626;
border: 1px solid #555;
border-radius: 6px;
}
input:focus, textarea:focus, select:focus { outline: 2px solid var(--gold); outline-offset: -1px; border-color: transparent; }
.inline { display: flex; gap: 8px; align-items: center; }
.inline input { flex: 1; }
.actions { display: flex; gap: 8px; margin-top: 18px; align-items: center; flex-wrap: wrap; }
.check { display: flex; gap: 8px; align-items: center; color: var(--text); font-size: 14px; margin: 8px 0; }
/* Messages */
.flash, .error {
padding: 10px 14px; border-radius: 6px; margin-bottom: 20px;
}
.flash { background: #2f3d2f; border: 1px solid #4b6b4b; }
.error { background: #4a2a28; border: 1px solid #7a3b36; }
/* Linked accounts */
.account-row {
display: flex; gap: 16px; align-items: flex-start;
padding: 16px 0; border-top: 1px solid var(--line);
}
.account-row:first-of-type { border-top: none; padding-top: 4px; }
.avatar {
width: 44px; height: 44px; border-radius: 50%; flex: none;
background: linear-gradient(135deg, #5a5a5a, #3a3a3a);
display: flex; align-items: center; justify-content: center;
font-weight: 700; color: var(--gold);
border: 1px solid #222;
}
.account-main { flex: 1; min-width: 0; }
.account-name { font-weight: 600; font-size: 16px; }
.tags { display: flex; gap: 6px; flex-wrap: wrap; margin: 6px 0; }
.tag {
font-size: 12px; padding: 1px 8px; border-radius: 999px;
background: #2a2a2a; border: 1px solid #555; color: var(--muted);
}
.tag.ok { color: var(--green); border-color: #3f6b3f; }
.tag.adult { color: var(--blue); border-color: #3b5f7a; }
.tag.online { color: #000; background: var(--green); border-color: var(--green); }
.tag.gold { color: #000; background: var(--gold); border-color: var(--gold); }
.tag.warn { color: var(--red); border-color: #7a3b36; }
.settings { display: flex; gap: 18px; flex-wrap: wrap; align-items: center; margin-top: 4px; }
.settings form { display: flex; gap: 18px; align-items: center; flex-wrap: wrap; margin: 0; }
.row-actions { display: flex; gap: 8px; flex: none; }
.row-actions form { margin: 0; }
.code {
font: 600 22px/1.2 ui-monospace, "Cascadia Mono", Consolas, monospace;
letter-spacing: 1px;
background: #222; border: 1px dashed var(--gold); color: var(--gold);
padding: 10px 14px; border-radius: 6px; display: inline-block; margin: 6px 0;
user-select: all;
}
ol.steps { padding-left: 20px; margin: 8px 0 0; }
ol.steps li { margin: 6px 0; }
.outfit { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
.item { font-size: 12px; padding: 2px 8px; border-radius: 4px; background: #2a2a2a; border: 1px solid #555; }
/* Tables */
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th { text-align: left; color: var(--muted); font-weight: 400; padding: 6px 8px; border-bottom: 1px solid var(--line); }
td { padding: 8px; border-bottom: 1px solid #424242; vertical-align: top; }
tr:last-child td { border-bottom: none; }
.empty { color: var(--muted); padding: 12px 0; }
.hero { text-align: center; padding: 30px 0 34px; }
.hero h1 { font-size: 34px; }
.hero .actions { justify-content: center; }
+82
View File
@@ -0,0 +1,82 @@
{{define "title"}}Account{{end}}
{{define "content"}}
<h1>Your account</h1>
<p class="lead">Signed in as {{.Person.Username}}{{if .Person.IsModerator}} <span class="tag gold">{{.Person.Role}}</span>{{end}}</p>
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
<div class="panel">
<h2>IMVU accounts <span class="count">{{len .Links}} of {{.Person.LinkCap}}</span></h2>
{{range .Links}}
<div class="account-row">
<div class="avatar">{{slice .Username 0 1 | upper}}</div>
<div class="account-main">
<div class="account-name">{{.Username}}</div>
<div class="tags">
{{if .Online}}<span class="tag online">online in T5DE</span>{{end}}
{{if .AgeVerified}}<span class="tag adult">age verified</span>{{else if .AccessPass}}<span class="tag adult">Access Pass</span>{{else}}<span class="tag">not age verified</span>{{end}}
<span class="tag">checked {{ago .CheckedAt}}</span>
</div>
{{if .Outfit}}
<div class="small muted">Wearing:</div>
<div class="outfit">{{range .Outfit}}<span class="item">{{.Name}}</span>{{end}}</div>
{{end}}
<div class="settings">
<form method="post" action="/account/links/{{.IMVUID}}/settings">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<label class="check"><input type="checkbox" name="badge" {{if .ShowBadge}}checked{{end}}> Show T5DE badge</label>
<label class="check"><input type="checkbox" name="presence" {{if .ShowPresence}}checked{{end}}> Show when I'm online in T5DE</label>
<button class="secondary">Save</button>
</form>
</div>
</div>
<div class="row-actions">
<form method="post" action="/account/links/{{.IMVUID}}/recheck"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="secondary" title="Check age verification with IMVU again">Check again</button></form>
<form method="post" action="/account/links/{{.IMVUID}}/unlink"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="secondary">Unlink</button></form>
</div>
</div>
{{else}}
<p class="empty">No IMVU accounts linked yet.</p>
{{end}}
</div>
{{range .Challenges}}
<div class="panel" id="link-{{.IMVUID}}">
<h2>Link {{.Username}}</h2>
<p>To prove {{.Username}} is yours:</p>
<ol class="steps">
<li>Put this code anywhere in {{.Username}}'s IMVU tagline (on your avatar card in the client, or your profile):<br>
<span class="code">{{.Code}}</span></li>
<li>Press Check. You can take the code out again once it's linked.</li>
</ol>
<div class="actions">
<form method="post" action="/account/link/{{.IMVUID}}/check"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button>Check</button></form>
<form method="post" action="/account/link/{{.IMVUID}}/cancel"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="secondary">Cancel</button></form>
<span class="muted small">The code works for a day.</span>
</div>
</div>
{{end}}
{{if .CanLink}}
<div class="panel">
<h2>Link an IMVU account</h2>
<form method="post" action="/account/link" class="inline">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="text" name="username" placeholder="IMVU username" required maxlength="64">
<button>Next</button>
</form>
<p class="muted small">Other people can't see which IMVU accounts are yours. Age-verified IMVU accounts (or with Access Pass)
can see adult community products; each account is checked on its own.</p>
</div>
{{else}}
<div class="panel"><p class="muted">You've linked as many IMVU accounts as you can ({{.Person.LinkCap}}). Ask a moderator if you need more.</p></div>
{{end}}
<div class="panel">
<h2>T5DE on your computers</h2>
<p class="muted">{{if .Signins}}Signed in on {{.Signins}} computer{{if ne .Signins 1}}s{{end}}.{{else}}Not signed in on any computer yet.{{end}}
To sign T5DE in, it shows a code to enter at <a href="/device">/device</a>.</p>
{{if .Signins}}
<form method="post" action="/account/proxies/signout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="secondary">Sign out everywhere</button></form>
{{end}}
</div>
{{end}}
+45
View File
@@ -0,0 +1,45 @@
{{define "title"}}Console{{end}}
{{define "content"}}
<h1>Console</h1>
<p class="lead">Moderation. <a href="/console/people">People</a></p>
<div class="grid">
<div class="panel"><h2>People</h2><div class="stat">{{.Counts.People}}</div><p class="muted small">accounts</p></div>
<div class="panel"><h2>IMVU accounts</h2><div class="stat">{{.Counts.Links}}</div><p class="muted small">linked</p></div>
<div class="panel"><h2>Computers</h2><div class="stat">{{.Counts.Signins}}</div><p class="muted small">signed in</p></div>
</div>
<div class="panel" hx-get="/console/online" hx-trigger="every 5s" hx-swap="innerHTML">
{{template "console-online" .}}
</div>
<div class="panel">
<h2>Audit log</h2>
<table>
<tr><th>When</th><th>Who</th><th>What</th><th>Target</th><th>Detail</th></tr>
{{range .Audit}}
<tr><td class="muted">{{date .At}}</td><td>{{if .Actor}}{{.Actor}}{{else}}<span class="muted">hub</span>{{end}}</td><td>{{.Action}}</td><td>{{.Target}}</td>
<td class="muted small">{{range $k, $v := .Detail}}{{$k}}: {{$v}} {{end}}</td></tr>
{{else}}
<tr><td colspan="5" class="empty">Nothing yet.</td></tr>
{{end}}
</table>
</div>
{{end}}
{{define "console-online"}}
<h2>Online now <span class="count">{{len .Online}} IMVU accounts, {{.Connections}} connections</span></h2>
{{range .Online}}
<div class="account-row">
<div class="avatar">{{slice .Link.Username 0 1 | upper}}</div>
<div class="account-main">
<div class="account-name">{{.Link.Username}} <a class="small" href="/console/people/{{.Online.PersonID}}">person</a></div>
<div class="tags">
<span class="tag online">since {{ago .Online.Since}}</span>
{{if .Hidden}}<span class="tag">presence hidden</span>{{end}}
{{if .Link.Adult}}<span class="tag adult">adult</span>{{end}}
</div>
{{if .Online.Outfit}}<div class="outfit">{{range .Online.Outfit}}<span class="item">{{.Name}}</span>{{end}}</div>{{end}}
</div>
</div>
{{else}}
<p class="empty">Nobody.</p>
{{end}}
{{end}}
@@ -0,0 +1,25 @@
{{define "title"}}People{{end}}
{{define "content"}}
<h1>People</h1>
<p class="lead"><a href="/console">Console</a></p>
<div class="panel">
<form method="get" action="/console/people" class="inline">
<input type="search" name="q" value="{{.Search}}" placeholder="T5DE or IMVU username">
<button class="secondary">Search</button>
</form>
</div>
<div class="panel">
<table>
<tr><th>Username</th><th>Role</th><th>Link cap</th><th>Joined</th><th></th></tr>
{{range .People}}
<tr>
<td><a href="/console/people/{{.ID}}">{{.Username}}</a></td>
<td>{{.Role}}</td><td>{{.LinkCap}}</td><td class="muted">{{date .CreatedAt}}</td>
<td>{{if .Banned}}<span class="tag warn">banned</span>{{end}}</td>
</tr>
{{else}}
<tr><td colspan="5" class="empty">Nobody found.</td></tr>
{{end}}
</table>
</div>
{{end}}
@@ -0,0 +1,61 @@
{{define "title"}}{{.Subject.Username}}{{end}}
{{define "content"}}
<h1>{{.Subject.Username}}</h1>
<p class="lead"><a href="/console/people">People</a> · {{.Subject.Role}} · joined {{date .Subject.CreatedAt}}
{{if .Subject.Banned}}<span class="tag warn">banned: {{.Subject.BanReason}}</span>{{end}}</p>
<div class="panel">
<h2>IMVU accounts <span class="count">{{len .Links}} of {{.Subject.LinkCap}}</span></h2>
<p class="muted small">Only moderators see which IMVU accounts belong to one person.</p>
<table>
<tr><th>IMVU account</th><th>ID</th><th>Linked</th><th>Age</th><th>Shows</th></tr>
{{range .Links}}
<tr><td>{{.Username}}</td><td class="muted">{{.IMVUID}}</td><td class="muted">{{date .LinkedAt}}</td>
<td>{{if .AgeVerified}}verified{{else if .AccessPass}}Access Pass{{else}}<span class="muted">no</span>{{end}}</td>
<td class="small">{{if .ShowBadge}}badge {{end}}{{if .ShowPresence}}presence{{end}}</td></tr>
{{else}}
<tr><td colspan="5" class="empty">None.</td></tr>
{{end}}
</table>
<p class="muted small">Signed in on {{.Signins}} computer{{if ne .Signins 1}}s{{end}}.</p>
</div>
<div class="grid">
<div class="panel">
<h2>Link cap</h2>
<form method="post" action="/console/people/{{.Subject.ID}}/cap" class="inline">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="number" name="cap" value="{{.Subject.LinkCap}}" min="0" max="1000">
<button>Set</button>
</form>
<p class="muted small">How many IMVU accounts they can link (normally 10).</p>
</div>
<div class="panel">
<h2>Ban</h2>
{{if .Subject.Banned}}
<form method="post" action="/console/people/{{.Subject.ID}}/unban"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="secondary">Unban</button></form>
{{else}}
<form method="post" action="/console/people/{{.Subject.ID}}/ban">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="text" name="reason" placeholder="Reason (they see it)" required maxlength="500">
<div class="actions"><button class="danger">Ban</button></div>
</form>
<p class="muted small">Covers all their IMVU accounts, and signs them out everywhere.</p>
{{end}}
</div>
{{if .Person.IsAdmin}}
<div class="panel">
<h2>Role</h2>
<form method="post" action="/console/people/{{.Subject.ID}}/role" class="inline">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<select name="role">
<option value="user"{{if eq .Subject.Role "user"}} selected{{end}}>User</option>
<option value="moderator"{{if eq .Subject.Role "moderator"}} selected{{end}}>Moderator</option>
<option value="admin"{{if eq .Subject.Role "admin"}} selected{{end}}>Admin</option>
</select>
<button>Set</button>
</form>
</div>
{{end}}
</div>
{{end}}
+20
View File
@@ -0,0 +1,20 @@
{{define "title"}}Sign T5DE in{{end}}
{{define "content"}}
<div class="panel narrow">
{{if .Approved}}
<h1>T5DE is signed in</h1>
<p class="muted">You can close this page and go back to T5DE.</p>
{{else}}
<h1>Sign T5DE in</h1>
<p class="muted">Enter the code T5DE shows, to sign it in as {{.Person.Username}}.</p>
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
<form method="post" action="/device">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label for="code">Code</label>
<input type="text" id="code" name="code" value="{{.Code}}" placeholder="XXXX-XXXX" required autofocus autocomplete="off">
<div class="actions"><button>Sign in</button></div>
</form>
<p class="muted small">Only enter a code you got from T5DE yourself.</p>
{{end}}
</div>
{{end}}
+8
View File
@@ -0,0 +1,8 @@
{{define "title"}}Sorry{{end}}
{{define "content"}}
<div class="panel narrow">
<h1>Sorry</h1>
<p>{{.Message}}</p>
<div class="actions"><a class="button secondary" href="/">Home</a></div>
</div>
{{end}}
+33
View File
@@ -0,0 +1,33 @@
{{define "title"}}Home{{end}}
{{define "content"}}
<div class="hero">
<h1>T5DE Hub</h1>
<p class="lead">Your T5DE account: link your IMVU accounts, see who's online in T5DE, and soon, community products.</p>
{{if not .Person}}
<div class="actions">
<a class="button" href="/signup">Create account</a>
<a class="button secondary" href="/signin">Sign in</a>
</div>
{{end}}
</div>
<div class="grid">
<div class="panel">
<h2>Online in T5DE</h2>
<div class="stat">{{.OnlineCount}}</div>
<p class="muted small">people showing that they're online. <a href="/online">See who</a></p>
</div>
{{if .Person}}
<div class="panel">
<h2>Your IMVU accounts</h2>
<div class="stat">{{len .Links}}</div>
<p class="muted small">linked. <a href="/account">Manage</a></p>
</div>
{{else}}
<div class="panel">
<h2>One account, all your avatars</h2>
<p class="muted small">Link any of your IMVU accounts by putting a code in its tagline. Your accounts stay private:
nobody else can see they're yours.</p>
</div>
{{end}}
</div>
{{end}}
+40
View File
@@ -0,0 +1,40 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{template "title" .}} · T5DE Hub</title>
<meta name="htmx-config" content='{"includeIndicatorStyles": false}'>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js" defer></script>
</head>
<body hx-headers='{"X-CSRF-Token": "{{.CSRF}}"}'>
<header class="top">
<div class="wrap">
<a class="brand" href="/"><span class="logo">T5DE</span> Hub</a>
<nav>
<a href="/online"{{if eq .Page "online"}} class="here"{{end}}>Online</a>
{{if .Person}}
<a href="/account"{{if eq .Page "account"}} class="here"{{end}}>Account</a>
{{if .Person.IsModerator}}<a href="/console"{{if eq .Page "console"}} class="here"{{end}}>Console</a>{{end}}
<span class="right">
<span class="muted small">{{.Person.Username}}</span>
<form method="post" action="/signout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="secondary">Sign out</button></form>
</span>
{{else}}
<span class="right">
<a href="/signin">Sign in</a>
<a class="button" href="/signup">Create account</a>
</span>
{{end}}
</nav>
</div>
</header>
<main class="wrap">
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
{{template "content" .}}
</main>
<footer class="wrap muted">T5DE is unofficial: not made by or affiliated with IMVU.</footer>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
{{define "title"}}Online{{end}}
{{define "content"}}
<h1>Online in T5DE</h1>
<p class="lead">IMVU accounts in a T5DE client right now, that chose to show it.</p>
<div class="panel" hx-get="/online/list" hx-trigger="every 5s" hx-swap="innerHTML">
{{template "online-list" .}}
</div>
{{end}}
{{define "online-list"}}
{{range .Online}}
<div class="account-row">
<div class="avatar">{{slice .Link.Username 0 1 | upper}}</div>
<div class="account-main">
<div class="account-name">{{.Link.Username}}</div>
<div class="tags">
{{if .Link.ShowBadge}}<span class="tag gold">T5DE</span>{{end}}
<span class="tag online">online {{ago .Online.Since}}</span>
{{if .Hidden}}<span class="tag warn">hidden: moderators only</span>{{end}}
</div>
{{if .Online.Outfit}}<div class="outfit">{{range .Online.Outfit}}<span class="item">{{.Name}}</span>{{end}}</div>{{end}}
</div>
</div>
{{else}}
<p class="empty">Nobody is showing that they're online right now.</p>
{{end}}
{{end}}
+19
View File
@@ -0,0 +1,19 @@
{{define "title"}}Sign in{{end}}
{{define "content"}}
<div class="panel narrow">
<h1>Sign in</h1>
<p class="muted">to your T5DE account.</p>
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
<form method="post" action="/signin">
<input type="hidden" name="next" value="{{.Next}}">
<label for="username">Username</label>
<input type="text" id="username" name="username" value="{{.Username}}" autocomplete="username" required autofocus>
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" required>
<div class="actions">
<button>Sign in</button>
<span class="muted small">No account? <a href="/signup">Create one</a></span>
</div>
</form>
</div>
{{end}}
+22
View File
@@ -0,0 +1,22 @@
{{define "title"}}Create account{{end}}
{{define "content"}}
<div class="panel narrow">
<h1>Create account</h1>
<p class="muted">One T5DE account for all your IMVU accounts.</p>
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
<form method="post" action="/signup">
<label for="username">Username</label>
<input type="text" id="username" name="username" value="{{.Username}}" autocomplete="username" required autofocus
pattern="[A-Za-z0-9_]{3,32}" title="3 to 32 letters, digits or underscores">
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="new-password" required minlength="10">
<label for="password2">Password again</label>
<input type="password" id="password2" name="password2" autocomplete="new-password" required minlength="10">
<p class="muted small">Not your IMVU password: use a new one.</p>
<div class="actions">
<button>Create account</button>
<span class="muted small">Have one? <a href="/signin">Sign in</a></span>
</div>
</form>
</div>
{{end}}
+409
View File
@@ -0,0 +1,409 @@
package web
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"testing"
"time"
"github.com/coder/websocket"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
"github.com/jackc/pgx/v5"
"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"
)
// The tests run the whole hub against a real Postgres (embedded, downloaded once) and a fake IMVU API.
var dbURL string
func TestMain(m *testing.M) {
dir, _ := os.MkdirTemp("", "t5de-hub-test")
cache := os.Getenv("T5DE_PG_CACHE")
if cache == "" {
home, _ := os.UserCacheDir()
cache = filepath.Join(home, "t5de-hub-test-postgres")
}
const port = 54339
pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().Port(port).
DataPath(filepath.Join(dir, "data")).RuntimePath(filepath.Join(dir, "rt")).CachePath(cache).
Logger(io.Discard).StartTimeout(2 * time.Minute))
if err := pg.Start(); err != nil {
fmt.Println("starting Postgres:", err)
os.Exit(1)
}
dbURL = fmt.Sprintf("postgres://postgres:postgres@localhost:%d/postgres?sslmode=disable", port)
code := m.Run()
pg.Stop()
os.RemoveAll(dir)
os.Exit(code)
}
// fakeIMVU is IMVU's public user API, with accounts the test controls.
type fakeIMVU struct {
mu sync.Mutex
users map[int64]*imvu.User
}
func (f *fakeIMVU) set(u imvu.User) {
f.mu.Lock()
defer f.mu.Unlock()
f.users[u.ID] = &u
}
func (f *fakeIMVU) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
var found *imvu.User
for _, u := range f.users {
if r.URL.Path == fmt.Sprintf("/user/user-%d", u.ID) || r.URL.Path == "/user" && strings.EqualFold(r.URL.Query().Get("username"), u.Username) {
found = u
}
}
den := map[string]any{}
if found != nil {
den[fmt.Sprintf("https://api.imvu.com/user/user-%d", found.ID)] = map[string]any{"data": map[string]any{
"legacy_cid": found.ID, "username": found.Username, "tagline": found.Tagline,
"is_ageverified": found.AgeVerified, "is_ap": found.AccessPass}}
}
json.NewEncoder(w).Encode(map[string]any{"status": "success", "id": r.URL.String(), "denormalized": den})
}
type hub struct {
t *testing.T
server *Server
http *httptest.Server
imvu *fakeIMVU
}
// newHub runs a hub on a fresh database.
func newHub(t *testing.T) *hub {
ctx := context.Background()
admin, err := pgx.Connect(ctx, dbURL)
if err != nil {
t.Fatal(err)
}
db := "t" + strings.ToLower(token.Random(6))
db = regexp.MustCompile(`[^a-z0-9]`).ReplaceAllString(db, "x")
if _, err := admin.Exec(ctx, "create database "+db); err != nil {
t.Fatal(err)
}
admin.Close(ctx)
st, err := store.Open(ctx, strings.Replace(dbURL, "/postgres?", "/"+db+"?", 1))
if err != nil {
t.Fatal(err)
}
t.Cleanup(st.Close)
fake := &fakeIMVU{users: map[int64]*imvu.User{}}
imvuServer := httptest.NewServer(fake)
t.Cleanup(imvuServer.Close)
keys, err := token.LoadOrCreate(filepath.Join(t.TempDir(), "key"), "http://hub.test")
if err != nil {
t.Fatal(err)
}
s := &Server{Store: st, IMVU: &imvu.Client{Base: imvuServer.URL, HTTP: imvuServer.Client()}, Keys: keys,
Log: log.New(io.Discard, "", 0)}
s.Realtime = realtime.New(Owners{Store: st}, s.Log)
handler, err := s.Handler()
if err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
s.PublicURL = srv.URL
return &hub{t: t, server: s, http: srv, imvu: fake}
}
// browser is a person on the website.
type browser struct {
h *hub
client *http.Client
csrf string
}
func (h *hub) browser() *browser {
jar, _ := cookiejar.New(nil)
return &browser{h: h, client: &http.Client{Jar: jar}}
}
var csrfPattern = regexp.MustCompile(`name="csrf" value="([^"]+)"`)
func (b *browser) get(path string) (int, string) {
b.h.t.Helper()
resp, err := b.client.Get(b.h.http.URL + path)
if err != nil {
b.h.t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if m := csrfPattern.FindSubmatch(body); m != nil {
b.csrf = string(m[1])
}
return resp.StatusCode, string(body)
}
func (b *browser) post(path string, form url.Values) (int, string) {
b.h.t.Helper()
if form == nil {
form = url.Values{}
}
if b.csrf != "" && form.Get("csrf") == "" {
form.Set("csrf", b.csrf)
}
req, _ := http.NewRequest("POST", b.h.http.URL+path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Sec-Fetch-Site", "same-origin")
resp, err := b.client.Do(req)
if err != nil {
b.h.t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if m := csrfPattern.FindSubmatch(body); m != nil {
b.csrf = string(m[1])
}
return resp.StatusCode, string(body)
}
func (b *browser) signup(username string) {
b.h.t.Helper()
status, body := b.post("/signup", url.Values{"username": {username}, "password": {"a long password"}, "password2": {"a long password"}})
if status != 200 || !strings.Contains(body, "Signed in as "+username) {
b.h.t.Fatalf("signup %s: %d %s", username, status, body)
}
}
var codePattern = regexp.MustCompile(`t5de-[A-Z0-9]{4}-[A-Z0-9]{4}`)
// link links an IMVU account through the website, putting the code in its fake tagline.
func (b *browser) link(u imvu.User) string {
b.h.t.Helper()
b.h.imvu.set(u)
_, body := b.post("/account/link", url.Values{"username": {u.Username}})
code := codePattern.FindString(body)
if code == "" {
b.h.t.Fatalf("no code: %s", body)
}
u.Tagline = "hello " + strings.ToLower(code)
b.h.imvu.set(u)
_, body = b.post(fmt.Sprintf("/account/link/%d/check", u.ID), nil)
return body
}
func TestSignupSigninAndCSRF(t *testing.T) {
h := newHub(t)
a := h.browser()
a.signup("alice")
// The first account is the admin.
if _, body := a.get("/account"); !strings.Contains(body, ">admin<") {
t.Fatal("first account isn't admin")
}
b := h.browser()
b.signup("bob")
if status, _ := b.get("/console"); status != http.StatusForbidden {
t.Fatalf("bob sees the console: %d", status)
}
// Taken usernames (any case), bad passwords.
c := h.browser()
if status, body := c.post("/signup", url.Values{"username": {"ALICE"}, "password": {"a long password"}, "password2": {"a long password"}}); status != 400 || !strings.Contains(body, "taken") {
t.Fatalf("%d %s", status, body)
}
if status, _ := c.post("/signup", url.Values{"username": {"carol"}, "password": {"short"}, "password2": {"short"}}); status != 400 {
t.Fatalf("short password: %d", status)
}
// Signing in, with the right and wrong password.
if status, body := c.post("/signin", url.Values{"username": {"bob"}, "password": {"wrong"}}); status != 400 || !strings.Contains(body, "Wrong") {
t.Fatalf("%d %s", status, body)
}
if _, body := c.post("/signin", url.Values{"username": {"Bob"}, "password": {"a long password"}}); !strings.Contains(body, "Signed in as bob") {
t.Fatal("sign-in failed")
}
// A form without the session's CSRF token is refused.
if status, _ := c.post("/account/link", url.Values{"username": {"x"}, "csrf": {"forged"}}); status != http.StatusForbidden {
t.Fatalf("forged CSRF: %d", status)
}
}
func TestLinkingByTagline(t *testing.T) {
h := newHub(t)
a := h.browser()
a.signup("alice")
// Without the code in the tagline, nothing is linked.
h.imvu.set(imvu.User{ID: 42, Username: "AliceAv"})
a.post("/account/link", url.Values{"username": {"aliceav"}})
if _, body := a.post("/account/link/42/check", nil); !strings.Contains(body, "doesn&#39;t have the code") {
t.Fatalf("linked without the code: %s", body)
}
body := a.link(imvu.User{ID: 42, Username: "AliceAv", AgeVerified: true})
if !strings.Contains(body, "Linked AliceAv") || !strings.Contains(body, "age verified") {
t.Fatalf("%s", body)
}
if _, body := a.post("/account/link", url.Values{"username": {"nobody"}}); !strings.Contains(body, "IMVU has no account") {
t.Fatalf("%s", body)
}
// Bob proves the same account is his (it was sold, say): it moves to him, with a new pairwise ID.
before, _ := h.server.Store.LinkByIMVUID(context.Background(), 42)
b := h.browser()
b.signup("bob")
b.link(imvu.User{ID: 42, Username: "AliceAv"})
after, _ := h.server.Store.LinkByIMVUID(context.Background(), 42)
if after.PersonID == before.PersonID || after.PairwiseID == before.PairwiseID {
t.Fatalf("not moved: %+v -> %+v", before, after)
}
if _, body := a.get("/account"); strings.Contains(body, "AliceAv") {
t.Fatal("alice still has the account")
}
// The link cap: bob may link only one more after a moderator lowers it.
bob, _ := h.server.Store.PersonByUsername(context.Background(), "bob")
a.get("/console/people/" + fmt.Sprint(bob.ID))
if status, _ := a.post(fmt.Sprintf("/console/people/%d/cap", bob.ID), url.Values{"cap": {"1"}}); status != 200 {
t.Fatalf("setting the cap: %d", status)
}
if body := b.link(imvu.User{ID: 43, Username: "BobAlt"}); !strings.Contains(body, "link up to 1") {
t.Fatalf("over the cap: %s", body)
}
}
func TestProxySignInAndPresence(t *testing.T) {
h := newHub(t)
a := h.browser()
a.signup("alice")
a.link(imvu.User{ID: 42, Username: "AliceAv"})
// The proxy asks for a code, alice enters it, the proxy gets tokens.
resp, err := http.Post(h.http.URL+"/api/v1/device/code", "", nil)
if err != nil {
t.Fatal(err)
}
var device struct{ DeviceCode, UserCode string }
var raw map[string]any
json.NewDecoder(resp.Body).Decode(&raw)
resp.Body.Close()
device.DeviceCode, device.UserCode = raw["device_code"].(string), raw["user_code"].(string)
poll := func() map[string]any {
resp, err := http.PostForm(h.http.URL+"/api/v1/device/token", url.Values{"device_code": {device.DeviceCode}})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out map[string]any
json.NewDecoder(resp.Body).Decode(&out)
return out
}
if out := poll(); out["error"] != "authorization_pending" {
t.Fatalf("%v", out)
}
a.get("/device")
if _, body := a.post("/device", url.Values{"code": {strings.ToLower(strings.ReplaceAll(device.UserCode, "-", ""))}}); !strings.Contains(body, "T5DE is signed in") {
t.Fatalf("%s", body)
}
tokens := poll()
access, _ := tokens["access_token"].(string)
if access == "" || tokens["refresh_token"] == "" {
t.Fatalf("%v", tokens)
}
if out := poll(); out["error"] != "expired_token" {
t.Fatalf("a code worked twice: %v", out)
}
// The refresh token rotates: the old one stops working.
refresh := func(tok string) map[string]any {
resp, err := http.PostForm(h.http.URL+"/api/v1/token/refresh", url.Values{"refresh_token": {tok}})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out map[string]any
json.NewDecoder(resp.Body).Decode(&out)
return out
}
next := refresh(tokens["refresh_token"].(string))
if next["access_token"] == nil || refresh(tokens["refresh_token"].(string))["error"] != "invalid_grant" {
t.Fatalf("rotation: %v", next)
}
// The live connection: an account that isn't alice's is refused; hers shows as online (to moderators, and to
// everyone once she opts in) with her community outfit.
ctx := context.Background()
ws, _, err := websocket.Dial(ctx, strings.Replace(h.http.URL, "http", "ws", 1)+"/api/v1/realtime",
&websocket.DialOptions{HTTPHeader: http.Header{"Authorization": {"Bearer " + access}}})
if err != nil {
t.Fatal(err)
}
defer ws.CloseNow()
read := func() map[string]any {
_, data, err := ws.Read(ctx)
if err != nil {
t.Fatal(err)
}
var m map[string]any
json.Unmarshal(data, &m)
return m
}
if m := read(); m["type"] != "welcome" {
t.Fatalf("%v", m)
}
ws.Write(ctx, websocket.MessageText, []byte(`{"type":"session","imvu_id":99}`))
if m := read(); m["message"] != "not_linked" {
t.Fatalf("%v", m)
}
ws.Write(ctx, websocket.MessageText, []byte(`{"type":"session","imvu_id":42}`))
ws.Write(ctx, websocket.MessageText, []byte(`{"type":"outfit","items":[{"id":"blue-coat","name":"Blue Coat"}]}`))
waitFor(t, func() bool { o := h.server.Realtime.Online(); return len(o) == 1 && len(o[0].Outfit) == 1 })
visitor := h.browser()
if _, body := visitor.get("/online"); strings.Contains(body, "AliceAv") {
t.Fatal("online shown without opting in")
}
if _, body := a.get("/console"); !strings.Contains(body, "AliceAv") || !strings.Contains(body, "Blue Coat") {
t.Fatal("the console doesn't show her")
}
a.post("/account/links/42/settings", url.Values{"presence": {"on"}, "badge": {"on"}})
if _, body := visitor.get("/online"); !strings.Contains(body, "AliceAv") || !strings.Contains(body, "Blue Coat") {
t.Fatal("online not shown after opting in")
}
// A ban ends the connection and the sign-in.
bob := h.browser()
bob.signup("bob")
alice, _ := h.server.Store.PersonByUsername(ctx, "alice")
h.server.Store.SetRole(ctx, alice.ID, "user")
bobP, _ := h.server.Store.PersonByUsername(ctx, "bob")
h.server.Store.SetRole(ctx, bobP.ID, "admin")
bob.get("/account")
if status, _ := bob.post(fmt.Sprintf("/console/people/%d/ban", alice.ID), url.Values{"reason": {"testing"}}); status != 200 {
t.Fatalf("ban: %d", status)
}
waitFor(t, func() bool { return len(h.server.Realtime.Online()) == 0 })
if out := refresh(next["refresh_token"].(string)); out["access_token"] != nil {
t.Fatal("a banned person's proxy can still sign in")
}
}
func waitFor(t *testing.T, ok func() bool) {
t.Helper()
for i := 0; i < 200; i++ {
if ok() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("timed out")
}