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,144 @@
|
||||
// Package imvu reads IMVU's public user API: who an account is, its tagline (for linking) and whether it's age
|
||||
// verified or has Access Pass. The hub calls it rarely (linking, and age checks at most daily) and never depends on
|
||||
// it for anything else.
|
||||
package imvu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// User is an IMVU account, as the public API shows it.
|
||||
type User struct {
|
||||
ID int64
|
||||
Username string
|
||||
Tagline string
|
||||
AgeVerified bool
|
||||
AccessPass bool
|
||||
}
|
||||
|
||||
// ErrNoSuchUser means IMVU has no account by that name or ID.
|
||||
var ErrNoSuchUser = errors.New("no such IMVU account")
|
||||
|
||||
// Client reads IMVU's API.
|
||||
type Client struct {
|
||||
// Base is the API's address; tests point it elsewhere.
|
||||
Base string
|
||||
HTTP *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
last time.Time
|
||||
}
|
||||
|
||||
// New is a client for api.imvu.com.
|
||||
func New() *Client {
|
||||
return &Client{Base: "https://api.imvu.com", HTTP: &http.Client{Timeout: 15 * time.Second}}
|
||||
}
|
||||
|
||||
// minInterval spaces out calls to IMVU.
|
||||
const minInterval = 200 * time.Millisecond
|
||||
|
||||
func (c *Client) wait(ctx context.Context) error {
|
||||
c.mu.Lock()
|
||||
next := c.last.Add(minInterval)
|
||||
now := time.Now()
|
||||
if next.Before(now) {
|
||||
next = now
|
||||
}
|
||||
c.last = next
|
||||
c.mu.Unlock()
|
||||
select {
|
||||
case <-time.After(time.Until(next)):
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// ByUsername looks an account up by name.
|
||||
func (c *Client) ByUsername(ctx context.Context, username string) (*User, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" || len(username) > 64 {
|
||||
return nil, ErrNoSuchUser
|
||||
}
|
||||
return c.get(ctx, "/user?username="+url.QueryEscape(username))
|
||||
}
|
||||
|
||||
// ByID reads an account (fresh, for checks).
|
||||
func (c *Client) ByID(ctx context.Context, id int64) (*User, error) {
|
||||
return c.get(ctx, "/user/user-"+strconv.FormatInt(id, 10))
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, path string) (*User, error) {
|
||||
if err := c.wait(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", c.Base+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "t5de-hub (https://git.t5de.dev/t5de/t5de-hub)")
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, ErrNoSuchUser
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("IMVU answered %d", resp.StatusCode)
|
||||
}
|
||||
return parse(body)
|
||||
}
|
||||
|
||||
// parse reads IMVU's "denormalized" answer: the user is the entry the answer's id points to, or the only user in it.
|
||||
func parse(body []byte) (*User, error) {
|
||||
var answer struct {
|
||||
Status string `json:"status"`
|
||||
ID string `json:"id"`
|
||||
Denormalized map[string]struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
} `json:"denormalized"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &answer); err != nil {
|
||||
return nil, fmt.Errorf("IMVU's answer: %w", err)
|
||||
}
|
||||
var found []json.RawMessage
|
||||
for key, entry := range answer.Denormalized {
|
||||
if strings.Contains(key, "/user/user-") {
|
||||
found = append(found, entry.Data)
|
||||
}
|
||||
}
|
||||
if len(found) != 1 {
|
||||
return nil, ErrNoSuchUser
|
||||
}
|
||||
var data struct {
|
||||
LegacyCID json.Number `json:"legacy_cid"`
|
||||
Username string `json:"username"`
|
||||
Tagline string `json:"tagline"`
|
||||
AgeVerified bool `json:"is_ageverified"`
|
||||
AP bool `json:"is_ap"`
|
||||
}
|
||||
if err := json.Unmarshal(found[0], &data); err != nil {
|
||||
return nil, fmt.Errorf("IMVU's user: %w", err)
|
||||
}
|
||||
id, err := data.LegacyCID.Int64()
|
||||
if err != nil || id <= 0 || data.Username == "" {
|
||||
return nil, ErrNoSuchUser
|
||||
}
|
||||
return &User{ID: id, Username: data.Username, Tagline: data.Tagline, AgeVerified: data.AgeVerified, AccessPass: data.AP}, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package imvu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestByUsername(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("username") == "Nobody" {
|
||||
w.Write([]byte(`{"status":"success","id":"x","denormalized":{}}`))
|
||||
return
|
||||
}
|
||||
w.Write([]byte(`{"status":"success","id":"https://api.imvu.com/user?username=Someone","denormalized":{
|
||||
"https://api.imvu.com/user/user-42":{"data":{"legacy_cid":42,"username":"Someone","tagline":"hi t5de-ABCD",
|
||||
"is_ageverified":true,"is_ap":false}}}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := &Client{Base: srv.URL, HTTP: srv.Client()}
|
||||
u, err := c.ByUsername(context.Background(), "Someone")
|
||||
if err != nil || u.ID != 42 || u.Username != "Someone" || u.Tagline != "hi t5de-ABCD" || !u.AgeVerified || u.AccessPass {
|
||||
t.Fatalf("%+v %v", u, err)
|
||||
}
|
||||
if _, err := c.ByUsername(context.Background(), "Nobody"); !errors.Is(err, ErrNoSuchUser) {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// Package realtime is the hub's live connection with signed-in proxies: which IMVU account each client is using
|
||||
// (presence), and the community products it's wearing. Later, room channels.
|
||||
//
|
||||
// Protocol (JSON text messages over a WebSocket at /api/v1/realtime, authorized with the proxy's access token):
|
||||
//
|
||||
// proxy -> hub {"type": "session", "imvu_id": 123} the client signed in as this IMVU account
|
||||
// proxy -> hub {"type": "outfit", "items": [{"id", "name"}]} the community products it wears now
|
||||
// hub -> proxy {"type": "welcome"} | {"type": "error", "message": "..."}
|
||||
package realtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// Item is a community product someone wears.
|
||||
type Item struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// Online is an IMVU account in use in a T5DE client now.
|
||||
type Online struct {
|
||||
PersonID int64
|
||||
IMVUID int64
|
||||
Since time.Time
|
||||
Outfit []Item
|
||||
}
|
||||
|
||||
// Owners tells the hub which person an IMVU account is linked to.
|
||||
type Owners interface {
|
||||
LinkOwner(ctx context.Context, imvuID int64) (personID int64, ok bool, err error)
|
||||
}
|
||||
|
||||
// Hub holds the connected proxies.
|
||||
type Hub struct {
|
||||
owners Owners
|
||||
log *log.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[*session]bool
|
||||
changed chan struct{} // closed and replaced on every change, for live views
|
||||
}
|
||||
|
||||
type session struct {
|
||||
personID int64
|
||||
conn *websocket.Conn
|
||||
imvuID int64
|
||||
since time.Time
|
||||
outfit []Item
|
||||
}
|
||||
|
||||
func New(owners Owners, logger *log.Logger) *Hub {
|
||||
return &Hub{owners: owners, log: logger, sessions: map[*session]bool{}, changed: make(chan struct{})}
|
||||
}
|
||||
|
||||
// Changed is closed the next time anyone's presence or outfit changes.
|
||||
func (h *Hub) Changed() <-chan struct{} {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.changed
|
||||
}
|
||||
|
||||
func (h *Hub) notifyLocked() {
|
||||
close(h.changed)
|
||||
h.changed = make(chan struct{})
|
||||
}
|
||||
|
||||
// Online lists the IMVU accounts in use now, one entry each (the most recent session), by IMVU ID.
|
||||
func (h *Hub) Online() []Online {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
byID := map[int64]Online{}
|
||||
for s := range h.sessions {
|
||||
if s.imvuID == 0 {
|
||||
continue
|
||||
}
|
||||
if prev, ok := byID[s.imvuID]; ok && prev.Since.After(s.since) {
|
||||
continue
|
||||
}
|
||||
byID[s.imvuID] = Online{PersonID: s.personID, IMVUID: s.imvuID, Since: s.since, Outfit: append([]Item(nil), s.outfit...)}
|
||||
}
|
||||
out := make([]Online, 0, len(byID))
|
||||
for _, o := range byID {
|
||||
out = append(out, o)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Since.Before(out[j].Since) })
|
||||
return out
|
||||
}
|
||||
|
||||
// Connections counts connected proxies (signed in or not to an IMVU account yet).
|
||||
func (h *Hub) Connections() int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return len(h.sessions)
|
||||
}
|
||||
|
||||
// Disconnect closes a person's connections (when they're banned or sign their proxies out).
|
||||
func (h *Hub) Disconnect(personID int64) {
|
||||
h.mu.Lock()
|
||||
var conns []*websocket.Conn
|
||||
for s := range h.sessions {
|
||||
if s.personID == personID {
|
||||
conns = append(conns, s.conn)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
for _, c := range conns {
|
||||
c.Close(websocket.StatusPolicyViolation, "signed out")
|
||||
}
|
||||
}
|
||||
|
||||
type message struct {
|
||||
Type string `json:"type"`
|
||||
IMVUID int64 `json:"imvu_id,omitempty"`
|
||||
Items []Item `json:"items,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// Serve runs one proxy's connection, already authorized as personID, until it closes.
|
||||
func (h *Hub) Serve(w http.ResponseWriter, r *http.Request, personID int64) {
|
||||
conn, err := websocket.Accept(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
conn.SetReadLimit(64 << 10)
|
||||
s := &session{personID: personID, conn: conn, since: time.Now()}
|
||||
h.mu.Lock()
|
||||
h.sessions[s] = true
|
||||
h.mu.Unlock()
|
||||
defer func() {
|
||||
h.mu.Lock()
|
||||
delete(h.sessions, s)
|
||||
if s.imvuID != 0 {
|
||||
h.notifyLocked()
|
||||
}
|
||||
h.mu.Unlock()
|
||||
conn.CloseNow()
|
||||
}()
|
||||
|
||||
ctx := r.Context()
|
||||
send := func(m message) error {
|
||||
b, _ := json.Marshal(m)
|
||||
wctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
return conn.Write(wctx, websocket.MessageText, b)
|
||||
}
|
||||
if send(message{Type: "welcome"}) != nil {
|
||||
return
|
||||
}
|
||||
// Keep the connection alive through proxies and NATs.
|
||||
go func() {
|
||||
t := time.NewTicker(30 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
pctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
err := conn.Ping(pctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
conn.CloseNow()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
for {
|
||||
_, data, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var m message
|
||||
if json.Unmarshal(data, &m) != nil {
|
||||
send(message{Type: "error", Message: "not a message"})
|
||||
continue
|
||||
}
|
||||
switch m.Type {
|
||||
case "session":
|
||||
owner, ok, err := h.owners.LinkOwner(ctx, m.IMVUID)
|
||||
if err != nil {
|
||||
send(message{Type: "error", Message: "try again later"})
|
||||
continue
|
||||
}
|
||||
if !ok || owner != personID {
|
||||
// Not this person's account: no presence, no outfit for it.
|
||||
h.set(s, func() { s.imvuID, s.outfit = 0, nil })
|
||||
send(message{Type: "error", Message: "not_linked"})
|
||||
continue
|
||||
}
|
||||
h.set(s, func() { s.imvuID, s.since, s.outfit = m.IMVUID, time.Now(), nil })
|
||||
case "outfit":
|
||||
if len(m.Items) > 64 {
|
||||
send(message{Type: "error", Message: "too many items"})
|
||||
continue
|
||||
}
|
||||
items := make([]Item, 0, len(m.Items))
|
||||
for _, it := range m.Items {
|
||||
if it.ID == "" || len(it.ID) > 128 || len(it.Name) > 128 {
|
||||
continue
|
||||
}
|
||||
items = append(items, it)
|
||||
}
|
||||
h.set(s, func() { s.outfit = items })
|
||||
default:
|
||||
send(message{Type: "error", Message: "unknown message type"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) set(s *session, change func()) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
change()
|
||||
h.notifyLocked()
|
||||
}
|
||||
|
||||
// ErrUnauthorized is returned by Authorize funcs for a missing or bad token.
|
||||
var ErrUnauthorized = errors.New("unauthorized")
|
||||
@@ -0,0 +1,78 @@
|
||||
-- Accounts (people), their linked IMVU accounts, website sessions, sign-ins from proxies, and the audit log.
|
||||
|
||||
create table persons (
|
||||
id bigserial primary key,
|
||||
username text not null,
|
||||
password_hash text not null,
|
||||
role text not null default 'user' check (role in ('user', 'moderator', 'admin')),
|
||||
-- How many IMVU accounts the person may link; moderators raise it on request.
|
||||
link_cap integer not null default 10 check (link_cap >= 0),
|
||||
created_at timestamptz not null default now(),
|
||||
banned_at timestamptz,
|
||||
ban_reason text not null default ''
|
||||
);
|
||||
create unique index persons_username on persons (lower(username));
|
||||
|
||||
create table sessions (
|
||||
token_hash bytea primary key,
|
||||
person_id bigint not null references persons on delete cascade,
|
||||
csrf text not null,
|
||||
created_at timestamptz not null default now(),
|
||||
expires_at timestamptz not null
|
||||
);
|
||||
create index sessions_person on sessions (person_id);
|
||||
|
||||
-- A linked IMVU account. Linking it again from another person moves it (the most recent proof wins).
|
||||
create table imvu_links (
|
||||
imvu_id bigint primary key,
|
||||
person_id bigint not null references persons on delete cascade,
|
||||
-- What catalogs and rooms see instead of the person, so a person's accounts can't be connected.
|
||||
pairwise_id text not null unique,
|
||||
username text not null,
|
||||
linked_at timestamptz not null default now(),
|
||||
age_verified boolean not null default false,
|
||||
access_pass boolean not null default false,
|
||||
checked_at timestamptz not null default now(),
|
||||
show_badge boolean not null default false,
|
||||
show_presence boolean not null default false
|
||||
);
|
||||
create index imvu_links_person on imvu_links (person_id);
|
||||
|
||||
-- A code the person is to put in an IMVU account's tagline, proving it's theirs.
|
||||
create table link_challenges (
|
||||
person_id bigint not null references persons on delete cascade,
|
||||
imvu_id bigint not null,
|
||||
username text not null,
|
||||
code text not null,
|
||||
created_at timestamptz not null default now(),
|
||||
primary key (person_id, imvu_id)
|
||||
);
|
||||
|
||||
-- Signing a proxy in: the proxy shows user_code, the person approves it on the website.
|
||||
create table device_codes (
|
||||
device_hash bytea primary key,
|
||||
user_code text not null unique,
|
||||
created_at timestamptz not null default now(),
|
||||
expires_at timestamptz not null,
|
||||
person_id bigint references persons on delete cascade,
|
||||
consumed_at timestamptz
|
||||
);
|
||||
|
||||
create table refresh_tokens (
|
||||
token_hash bytea primary key,
|
||||
person_id bigint not null references persons on delete cascade,
|
||||
created_at timestamptz not null default now(),
|
||||
last_used_at timestamptz not null default now(),
|
||||
revoked_at timestamptz
|
||||
);
|
||||
create index refresh_tokens_person on refresh_tokens (person_id);
|
||||
|
||||
create table audit (
|
||||
id bigserial primary key,
|
||||
at timestamptz not null default now(),
|
||||
actor_id bigint references persons on delete set null,
|
||||
action text not null,
|
||||
target text not null default '',
|
||||
detail jsonb not null default '{}'
|
||||
);
|
||||
create index audit_at on audit (at desc);
|
||||
@@ -0,0 +1,529 @@
|
||||
// Package store is the hub's data in Postgres: people, their linked IMVU accounts, sessions, proxy sign-ins and
|
||||
// the audit log. Plain SQL with pgx.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrations embed.FS
|
||||
|
||||
// Store is the hub's database.
|
||||
type Store struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
// ErrNotFound means there's no such row.
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// ErrTaken means a unique value (a username) is in use.
|
||||
var ErrTaken = errors.New("taken")
|
||||
|
||||
// Open connects to Postgres and brings its schema up to date.
|
||||
func Open(ctx context.Context, url string) (*Store, error) {
|
||||
db, err := pgxpool.New(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() { s.db.Close() }
|
||||
|
||||
// migrate applies the migrations not applied yet, in name order, each in a transaction.
|
||||
func (s *Store) migrate(ctx context.Context) error {
|
||||
if _, err := s.db.Exec(ctx, `create table if not exists schema_migrations (name text primary key, applied_at timestamptz not null default now())`); err != nil {
|
||||
return err
|
||||
}
|
||||
names, err := fs.Glob(migrations, "migrations/*.sql")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
var done bool
|
||||
if err := s.db.QueryRow(ctx, `select exists (select 1 from schema_migrations where name = $1)`, name).Scan(&done); err != nil {
|
||||
return err
|
||||
}
|
||||
if done {
|
||||
continue
|
||||
}
|
||||
sql, _ := migrations.ReadFile(name)
|
||||
err := pgx.BeginFunc(ctx, s.db, func(tx pgx.Tx) error {
|
||||
if _, err := tx.Exec(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(ctx, `insert into schema_migrations (name) values ($1)`, name)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("migration %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Hash is how tokens are stored: never the token itself.
|
||||
func Hash(token string) []byte {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
return h[:]
|
||||
}
|
||||
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pg *pgconn.PgError
|
||||
return errors.As(err, &pg) && pg.Code == "23505"
|
||||
}
|
||||
|
||||
// Person is a hub account: a person.
|
||||
type Person struct {
|
||||
ID int64
|
||||
Username string
|
||||
PasswordHash string
|
||||
Role string
|
||||
LinkCap int
|
||||
CreatedAt time.Time
|
||||
BannedAt *time.Time
|
||||
BanReason string
|
||||
}
|
||||
|
||||
func (p *Person) IsModerator() bool { return p.Role == "moderator" || p.Role == "admin" }
|
||||
func (p *Person) IsAdmin() bool { return p.Role == "admin" }
|
||||
func (p *Person) Banned() bool { return p.BannedAt != nil }
|
||||
|
||||
const personColumns = `id, username, password_hash, role, link_cap, created_at, banned_at, ban_reason`
|
||||
|
||||
func scanPerson(row pgx.Row) (*Person, error) {
|
||||
var p Person
|
||||
err := row.Scan(&p.ID, &p.Username, &p.PasswordHash, &p.Role, &p.LinkCap, &p.CreatedAt, &p.BannedAt, &p.BanReason)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &p, err
|
||||
}
|
||||
|
||||
// CreatePerson makes an account. The first account is an admin.
|
||||
func (s *Store) CreatePerson(ctx context.Context, username, passwordHash string) (*Person, error) {
|
||||
p, err := scanPerson(s.db.QueryRow(ctx, `insert into persons (username, password_hash, role)
|
||||
values ($1, $2, case when exists (select 1 from persons) then 'user' else 'admin' end)
|
||||
returning `+personColumns, username, passwordHash))
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrTaken
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
|
||||
func (s *Store) PersonByID(ctx context.Context, id int64) (*Person, error) {
|
||||
return scanPerson(s.db.QueryRow(ctx, `select `+personColumns+` from persons where id = $1`, id))
|
||||
}
|
||||
|
||||
func (s *Store) PersonByUsername(ctx context.Context, username string) (*Person, error) {
|
||||
return scanPerson(s.db.QueryRow(ctx, `select `+personColumns+` from persons where lower(username) = lower($1)`, username))
|
||||
}
|
||||
|
||||
// People lists accounts, newest first, optionally filtered by a username search.
|
||||
func (s *Store) People(ctx context.Context, search string, limit int) ([]Person, error) {
|
||||
rows, err := s.db.Query(ctx, `select `+personColumns+` from persons
|
||||
where $1 = '' or lower(username) like '%' || lower($1) || '%'
|
||||
or id in (select person_id from imvu_links where lower(username) like '%' || lower($1) || '%')
|
||||
order by id desc limit $2`, search, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Person
|
||||
for rows.Next() {
|
||||
p, err := scanPerson(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) SetRole(ctx context.Context, id int64, role string) error {
|
||||
_, err := s.db.Exec(ctx, `update persons set role = $2 where id = $1`, id, role)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SetLinkCap(ctx context.Context, id int64, cap int) error {
|
||||
_, err := s.db.Exec(ctx, `update persons set link_cap = $2 where id = $1`, id, cap)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetBanned bans (with a reason) or unbans a person; a ban also ends their sessions and sign-ins.
|
||||
func (s *Store) SetBanned(ctx context.Context, id int64, banned bool, reason string) error {
|
||||
return pgx.BeginFunc(ctx, s.db, func(tx pgx.Tx) error {
|
||||
if !banned {
|
||||
_, err := tx.Exec(ctx, `update persons set banned_at = null, ban_reason = '' where id = $1`, id)
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `update persons set banned_at = now(), ban_reason = $2 where id = $1`, id, reason); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `delete from sessions where person_id = $1`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(ctx, `update refresh_tokens set revoked_at = now() where person_id = $1 and revoked_at is null`, id)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// Sessions
|
||||
|
||||
func (s *Store) CreateSession(ctx context.Context, token string, personID int64, csrf string, expires time.Time) error {
|
||||
_, err := s.db.Exec(ctx, `insert into sessions (token_hash, person_id, csrf, expires_at) values ($1, $2, $3, $4)`,
|
||||
Hash(token), personID, csrf, expires)
|
||||
return err
|
||||
}
|
||||
|
||||
// Session is the person and CSRF token of a live session.
|
||||
func (s *Store) Session(ctx context.Context, token string) (*Person, string, error) {
|
||||
var csrf string
|
||||
var p Person
|
||||
err := s.db.QueryRow(ctx, `select s.csrf, `+prefixed("p", personColumns)+` from sessions s join persons p on p.id = s.person_id
|
||||
where s.token_hash = $1 and s.expires_at > now()`, Hash(token)).
|
||||
Scan(&csrf, &p.ID, &p.Username, &p.PasswordHash, &p.Role, &p.LinkCap, &p.CreatedAt, &p.BannedAt, &p.BanReason)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, "", ErrNotFound
|
||||
}
|
||||
return &p, csrf, err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSession(ctx context.Context, token string) error {
|
||||
_, err := s.db.Exec(ctx, `delete from sessions where token_hash = $1`, Hash(token))
|
||||
return err
|
||||
}
|
||||
|
||||
func prefixed(alias, columns string) string {
|
||||
parts := strings.Split(columns, ", ")
|
||||
for i := range parts {
|
||||
parts[i] = alias + "." + parts[i]
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// Linked IMVU accounts
|
||||
|
||||
// Link is a linked IMVU account.
|
||||
type Link struct {
|
||||
IMVUID int64
|
||||
PersonID int64
|
||||
PairwiseID string
|
||||
Username string
|
||||
LinkedAt time.Time
|
||||
AgeVerified bool
|
||||
AccessPass bool
|
||||
CheckedAt time.Time
|
||||
ShowBadge bool
|
||||
ShowPresence bool
|
||||
}
|
||||
|
||||
// Adult reports whether the account may see adult content: age verified by IMVU, or with Access Pass.
|
||||
func (l *Link) Adult() bool { return l.AgeVerified || l.AccessPass }
|
||||
|
||||
const linkColumns = `imvu_id, person_id, pairwise_id, username, linked_at, age_verified, access_pass, checked_at, show_badge, show_presence`
|
||||
|
||||
func scanLink(row pgx.Row) (*Link, error) {
|
||||
var l Link
|
||||
err := row.Scan(&l.IMVUID, &l.PersonID, &l.PairwiseID, &l.Username, &l.LinkedAt, &l.AgeVerified, &l.AccessPass, &l.CheckedAt, &l.ShowBadge, &l.ShowPresence)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &l, err
|
||||
}
|
||||
|
||||
func (s *Store) Links(ctx context.Context, personID int64) ([]Link, error) {
|
||||
rows, err := s.db.Query(ctx, `select `+linkColumns+` from imvu_links where person_id = $1 order by linked_at`, personID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Link
|
||||
for rows.Next() {
|
||||
l, err := scanLink(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) LinkByIMVUID(ctx context.Context, imvuID int64) (*Link, error) {
|
||||
return scanLink(s.db.QueryRow(ctx, `select `+linkColumns+` from imvu_links where imvu_id = $1`, imvuID))
|
||||
}
|
||||
|
||||
// ErrLinkCap means the person has linked as many accounts as they may.
|
||||
var ErrLinkCap = errors.New("link cap reached")
|
||||
|
||||
// LinkAccount links an IMVU account to a person, moving it from whoever had it (the most recent proof wins).
|
||||
// It returns the person it was moved from, if any.
|
||||
func (s *Store) LinkAccount(ctx context.Context, personID, imvuID int64, username, pairwiseID string, ageVerified, accessPass bool) (movedFrom int64, err error) {
|
||||
err = pgx.BeginFunc(ctx, s.db, func(tx pgx.Tx) error {
|
||||
var cap, count int
|
||||
if err := tx.QueryRow(ctx, `select link_cap, (select count(*) from imvu_links where person_id = $1 and imvu_id <> $2)
|
||||
from persons where id = $1 for update`, personID, imvuID).Scan(&cap, &count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= cap {
|
||||
return ErrLinkCap
|
||||
}
|
||||
err := tx.QueryRow(ctx, `select person_id from imvu_links where imvu_id = $1`, imvuID).Scan(&movedFrom)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
if movedFrom == personID {
|
||||
movedFrom = 0
|
||||
}
|
||||
// A moved account gets a new pairwise ID: nothing connects its new owner to the old one.
|
||||
_, err = tx.Exec(ctx, `insert into imvu_links (imvu_id, person_id, pairwise_id, username, age_verified, access_pass)
|
||||
values ($1, $2, $3, $4, $5, $6)
|
||||
on conflict (imvu_id) do update set person_id = excluded.person_id,
|
||||
pairwise_id = case when imvu_links.person_id = excluded.person_id then imvu_links.pairwise_id else excluded.pairwise_id end,
|
||||
username = excluded.username, age_verified = excluded.age_verified, access_pass = excluded.access_pass,
|
||||
checked_at = now(),
|
||||
linked_at = case when imvu_links.person_id = excluded.person_id then imvu_links.linked_at else now() end,
|
||||
show_badge = case when imvu_links.person_id = excluded.person_id then imvu_links.show_badge else false end,
|
||||
show_presence = case when imvu_links.person_id = excluded.person_id then imvu_links.show_presence else false end`,
|
||||
imvuID, personID, pairwiseID, username, ageVerified, accessPass)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `delete from link_challenges where imvu_id = $1`, imvuID)
|
||||
return err
|
||||
})
|
||||
return movedFrom, err
|
||||
}
|
||||
|
||||
func (s *Store) Unlink(ctx context.Context, personID, imvuID int64) error {
|
||||
_, err := s.db.Exec(ctx, `delete from imvu_links where person_id = $1 and imvu_id = $2`, personID, imvuID)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateAge records a fresh age check of a linked account.
|
||||
func (s *Store) UpdateAge(ctx context.Context, imvuID int64, username string, ageVerified, accessPass bool) error {
|
||||
_, err := s.db.Exec(ctx, `update imvu_links set username = $2, age_verified = $3, access_pass = $4, checked_at = now() where imvu_id = $1`,
|
||||
imvuID, username, ageVerified, accessPass)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SetLinkSettings(ctx context.Context, personID, imvuID int64, badge, presence bool) error {
|
||||
_, err := s.db.Exec(ctx, `update imvu_links set show_badge = $3, show_presence = $4 where person_id = $1 and imvu_id = $2`,
|
||||
personID, imvuID, badge, presence)
|
||||
return err
|
||||
}
|
||||
|
||||
// Challenge is a pending tagline code.
|
||||
type Challenge struct {
|
||||
IMVUID int64
|
||||
Username string
|
||||
Code string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// SetChallenge stores (or replaces) the code for linking an account.
|
||||
func (s *Store) SetChallenge(ctx context.Context, personID, imvuID int64, username, code string) error {
|
||||
_, err := s.db.Exec(ctx, `insert into link_challenges (person_id, imvu_id, username, code) values ($1, $2, $3, $4)
|
||||
on conflict (person_id, imvu_id) do update set code = excluded.code, username = excluded.username, created_at = now()`,
|
||||
personID, imvuID, username, code)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Challenges(ctx context.Context, personID int64) ([]Challenge, error) {
|
||||
rows, err := s.db.Query(ctx, `select imvu_id, username, code, created_at from link_challenges
|
||||
where person_id = $1 and created_at > now() - interval '1 day' order by created_at`, personID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Challenge
|
||||
for rows.Next() {
|
||||
var c Challenge
|
||||
if err := rows.Scan(&c.IMVUID, &c.Username, &c.Code, &c.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) Challenge(ctx context.Context, personID, imvuID int64) (*Challenge, error) {
|
||||
var c Challenge
|
||||
err := s.db.QueryRow(ctx, `select imvu_id, username, code, created_at from link_challenges
|
||||
where person_id = $1 and imvu_id = $2 and created_at > now() - interval '1 day'`, personID, imvuID).
|
||||
Scan(&c.IMVUID, &c.Username, &c.Code, &c.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &c, err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteChallenge(ctx context.Context, personID, imvuID int64) error {
|
||||
_, err := s.db.Exec(ctx, `delete from link_challenges where person_id = $1 and imvu_id = $2`, personID, imvuID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Proxy sign-in (device codes) and refresh tokens
|
||||
|
||||
func (s *Store) CreateDeviceCode(ctx context.Context, deviceCode, userCode string, expires time.Time) error {
|
||||
_, err := s.db.Exec(ctx, `insert into device_codes (device_hash, user_code, expires_at) values ($1, $2, $3)`,
|
||||
Hash(deviceCode), userCode, expires)
|
||||
if isUniqueViolation(err) {
|
||||
return ErrTaken
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ApproveDeviceCode lets the person's proxy waiting with userCode sign in.
|
||||
func (s *Store) ApproveDeviceCode(ctx context.Context, userCode string, personID int64) error {
|
||||
tag, err := s.db.Exec(ctx, `update device_codes set person_id = $2
|
||||
where user_code = $1 and person_id is null and consumed_at is null and expires_at > now()`, userCode, personID)
|
||||
if err == nil && tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// DeviceStatus is a waiting proxy's sign-in: approved (with the person), pending, or gone.
|
||||
type DeviceStatus int
|
||||
|
||||
const (
|
||||
DevicePending DeviceStatus = iota
|
||||
DeviceApproved
|
||||
DeviceGone
|
||||
)
|
||||
|
||||
// ConsumeDeviceCode reports a device code's state; an approved one is used up by this call.
|
||||
func (s *Store) ConsumeDeviceCode(ctx context.Context, deviceCode string) (DeviceStatus, int64, error) {
|
||||
var personID *int64
|
||||
var expires time.Time
|
||||
var consumed *time.Time
|
||||
err := s.db.QueryRow(ctx, `select person_id, expires_at, consumed_at from device_codes where device_hash = $1`, Hash(deviceCode)).
|
||||
Scan(&personID, &expires, &consumed)
|
||||
if errors.Is(err, pgx.ErrNoRows) || err == nil && (consumed != nil || time.Now().After(expires)) {
|
||||
return DeviceGone, 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if personID == nil {
|
||||
return DevicePending, 0, nil
|
||||
}
|
||||
tag, err := s.db.Exec(ctx, `update device_codes set consumed_at = now() where device_hash = $1 and consumed_at is null`, Hash(deviceCode))
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return DeviceGone, 0, nil
|
||||
}
|
||||
return DeviceApproved, *personID, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateRefreshToken(ctx context.Context, token string, personID int64) error {
|
||||
_, err := s.db.Exec(ctx, `insert into refresh_tokens (token_hash, person_id) values ($1, $2)`, Hash(token), personID)
|
||||
return err
|
||||
}
|
||||
|
||||
// RotateRefreshToken trades a live refresh token for a new one, returning the person it's for.
|
||||
func (s *Store) RotateRefreshToken(ctx context.Context, old, replacement string) (int64, error) {
|
||||
var personID int64
|
||||
err := pgx.BeginFunc(ctx, s.db, func(tx pgx.Tx) error {
|
||||
err := tx.QueryRow(ctx, `update refresh_tokens set revoked_at = now()
|
||||
where token_hash = $1 and revoked_at is null returning person_id`, Hash(old)).Scan(&personID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `insert into refresh_tokens (token_hash, person_id) values ($1, $2)`, Hash(replacement), personID)
|
||||
return err
|
||||
})
|
||||
return personID, err
|
||||
}
|
||||
|
||||
// Signins counts a person's signed-in proxies.
|
||||
func (s *Store) Signins(ctx context.Context, personID int64) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRow(ctx, `select count(*) from refresh_tokens where person_id = $1 and revoked_at is null`, personID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// SignOutProxies ends all of a person's proxy sign-ins.
|
||||
func (s *Store) SignOutProxies(ctx context.Context, personID int64) error {
|
||||
_, err := s.db.Exec(ctx, `update refresh_tokens set revoked_at = now() where person_id = $1 and revoked_at is null`, personID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Audit log
|
||||
|
||||
// Audit records something a person (or the hub, actor 0) did.
|
||||
func (s *Store) Audit(ctx context.Context, actorID int64, action, target string, detail map[string]any) error {
|
||||
var actor *int64
|
||||
if actorID != 0 {
|
||||
actor = &actorID
|
||||
}
|
||||
if detail == nil {
|
||||
detail = map[string]any{}
|
||||
}
|
||||
_, err := s.db.Exec(ctx, `insert into audit (actor_id, action, target, detail) values ($1, $2, $3, $4)`, actor, action, target, detail)
|
||||
return err
|
||||
}
|
||||
|
||||
// AuditEntry is a line of the audit log.
|
||||
type AuditEntry struct {
|
||||
At time.Time
|
||||
Actor string
|
||||
Action string
|
||||
Target string
|
||||
Detail map[string]any
|
||||
}
|
||||
|
||||
func (s *Store) AuditLog(ctx context.Context, limit int) ([]AuditEntry, error) {
|
||||
rows, err := s.db.Query(ctx, `select a.at, coalesce(p.username, ''), a.action, a.target, a.detail
|
||||
from audit a left join persons p on p.id = a.actor_id order by a.id desc limit $1`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AuditEntry
|
||||
for rows.Next() {
|
||||
var e AuditEntry
|
||||
if err := rows.Scan(&e.At, &e.Actor, &e.Action, &e.Target, &e.Detail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Counts are totals for the console.
|
||||
type Counts struct {
|
||||
People, Links, Signins int
|
||||
}
|
||||
|
||||
func (s *Store) Counts(ctx context.Context) (Counts, error) {
|
||||
var c Counts
|
||||
err := s.db.QueryRow(ctx, `select (select count(*) from persons), (select count(*) from imvu_links),
|
||||
(select count(*) from refresh_tokens where revoked_at is null)`).Scan(&c.People, &c.Links, &c.Signins)
|
||||
return c, err
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Package token issues and checks the hub's access tokens: JWTs signed with Ed25519 ("EdDSA"), short-lived. The
|
||||
// public keys are published (JWKS), so catalogs can check tokens without asking the hub.
|
||||
package token
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Claims are what a token says.
|
||||
type Claims struct {
|
||||
Issuer string `json:"iss"`
|
||||
Subject string `json:"sub"` // "person:<id>" for the hub's own API
|
||||
Audience string `json:"aud"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
Expires int64 `json:"exp"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
// Keys signs and checks tokens.
|
||||
type Keys struct {
|
||||
id string
|
||||
private ed25519.PrivateKey
|
||||
issuer string
|
||||
}
|
||||
|
||||
// LoadOrCreate reads the signing key from path, making one if there's none.
|
||||
func LoadOrCreate(path, issuer string) (*Keys, error) {
|
||||
seed, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
seed = make([]byte, ed25519.SeedSize)
|
||||
if _, err := rand.Read(seed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(path, seed, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(seed) != ed25519.SeedSize {
|
||||
return nil, fmt.Errorf("%s: not an Ed25519 seed", path)
|
||||
}
|
||||
key := ed25519.NewKeyFromSeed(seed)
|
||||
pub := key.Public().(ed25519.PublicKey)
|
||||
return &Keys{id: b64(pub[:8]), private: key, issuer: issuer}, nil
|
||||
}
|
||||
|
||||
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||
|
||||
// Issue signs a token for subject and audience, valid for ttl.
|
||||
func (k *Keys) Issue(subject, audience, scope string, ttl time.Duration) (string, error) {
|
||||
now := time.Now()
|
||||
header, _ := json.Marshal(map[string]string{"alg": "EdDSA", "typ": "JWT", "kid": k.id})
|
||||
claims, _ := json.Marshal(Claims{Issuer: k.issuer, Subject: subject, Audience: audience, IssuedAt: now.Unix(),
|
||||
Expires: now.Add(ttl).Unix(), Scope: scope})
|
||||
signing := b64(header) + "." + b64(claims)
|
||||
return signing + "." + b64(ed25519.Sign(k.private, []byte(signing))), nil
|
||||
}
|
||||
|
||||
// ErrInvalid means a token is malformed, forged, for someone else, or expired.
|
||||
var ErrInvalid = errors.New("invalid token")
|
||||
|
||||
// Check verifies a token for audience and returns its claims.
|
||||
func (k *Keys) Check(token, audience string) (*Claims, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
headerJSON, err1 := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
claimsJSON, err2 := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
sig, err3 := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err1 != nil || err2 != nil || err3 != nil {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
var header struct{ Alg, Kid string }
|
||||
if json.Unmarshal(headerJSON, &header) != nil || header.Alg != "EdDSA" || header.Kid != k.id {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
if !ed25519.Verify(k.private.Public().(ed25519.PublicKey), []byte(parts[0]+"."+parts[1]), sig) {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
var c Claims
|
||||
if json.Unmarshal(claimsJSON, &c) != nil || c.Issuer != k.issuer || c.Audience != audience || time.Now().Unix() >= c.Expires {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// JWKS is the public key set, as JSON (RFC 8037 OKP keys).
|
||||
func (k *Keys) JWKS() []byte {
|
||||
pub := k.private.Public().(ed25519.PublicKey)
|
||||
b, _ := json.Marshal(map[string]any{"keys": []map[string]string{{
|
||||
"kty": "OKP", "crv": "Ed25519", "alg": "EdDSA", "use": "sig", "kid": k.id, "x": b64(pub),
|
||||
}}})
|
||||
return b
|
||||
}
|
||||
|
||||
// Random is a random URL-safe string of n bytes' entropy, for session and refresh tokens.
|
||||
func Random(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b64(b)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package token
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIssueAndCheck(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "key")
|
||||
k, err := LoadOrCreate(path, "https://hub.test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tok, _ := k.Issue("person:1", "t5de-hub", "proxy", time.Minute)
|
||||
c, err := k.Check(tok, "t5de-hub")
|
||||
if err != nil || c.Subject != "person:1" || c.Scope != "proxy" {
|
||||
t.Fatalf("%+v %v", c, err)
|
||||
}
|
||||
if _, err := k.Check(tok, "a-catalog"); err == nil {
|
||||
t.Fatal("accepted for another audience")
|
||||
}
|
||||
// The same key comes back from the file.
|
||||
k2, _ := LoadOrCreate(path, "https://hub.test")
|
||||
if _, err := k2.Check(tok, "t5de-hub"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Tampering and expiry are caught.
|
||||
parts := strings.Split(tok, ".")
|
||||
forged := parts[0] + "." + b64([]byte(`{"iss":"https://hub.test","sub":"person:2","aud":"t5de-hub","exp":9999999999}`)) + "." + parts[2]
|
||||
if _, err := k.Check(forged, "t5de-hub"); err == nil {
|
||||
t.Fatal("accepted a forged token")
|
||||
}
|
||||
old, _ := k.Issue("person:1", "t5de-hub", "", -time.Second)
|
||||
if _, err := k.Check(old, "t5de-hub"); err == nil {
|
||||
t.Fatal("accepted an expired token")
|
||||
}
|
||||
var jwks struct{ Keys []map[string]string }
|
||||
if json.Unmarshal(k.JWKS(), &jwks) != nil || len(jwks.Keys) != 1 || jwks.Keys[0]["crv"] != "Ed25519" {
|
||||
t.Fatalf("%s", k.JWKS())
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -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+".")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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 |
Vendored
+1
@@ -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
|
||||
@@ -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; }
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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>
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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'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")
|
||||
}
|
||||
Reference in new issue
Block a user