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