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]>
279 lines
8.6 KiB
Go
279 lines
8.6 KiB
Go
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) }
|