Hub: accounts, IMVU linking by tagline, proxy sign-in, presence, console
CI / test (push) Failing after 42s
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:
1 parent
54e569bea3
commit
b9dc3f0ec1
36 files changed
+3548
-5
No files matched your search
@@ -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+".")
|
||||
}
|
||||
Reference in new issue
Block a user