Hub: accounts, IMVU linking by tagline, proxy sign-in, presence, console
CI / test (push) Failing after 42s

People sign up on the website and link IMVU accounts by putting a code in the account's tagline, which the
hub reads from IMVU's public API (the most recent proof wins; link caps per person). IMVU's age verification
or Access Pass make an account adult. T5DE's proxy signs in with a device code and keeps a WebSocket open,
reporting the IMVU account in use and the community products it wears; /online shows those who opted in.
Moderators get a console: who's online, people, link caps, bans, roles, and the audit log. Tests run the
website and API against an embedded Postgres and a fake IMVU API.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-29 03:45:52 -07:00
1 parent 54e569bea3
commit b9dc3f0ec1
36 files changed
+3548 -5

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
# Tests the hub (against a real Postgres: embedded, downloaded by the tests and cached) and builds it.
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
env:
GOPRIVATE: git.t5de.dev
T5DE_PG_CACHE: /tmp/t5de-hub-postgres
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Cache Postgres for the tests
uses: actions/cache@v4
with:
path: /tmp/t5de-hub-postgres
key: embedded-postgres-${{ runner.os }}-${{ hashFiles('go.sum') }}
- name: Test
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then echo "Not gofmt-formatted: $unformatted"; exit 1; fi
go vet ./...
go test ./...
- name: Build
run: CGO_ENABLED=0 go build -trimpath -o dist/t5de-hub ./cmd/t5de-hub
+2
View File
@@ -0,0 +1,2 @@
/dist/
/t5de-hub-data/
+10
View File
@@ -11,3 +11,13 @@ beside this one: `t5de` (patches), `t5de-proxy` (local proxy), later `t5de-catal
- Commits: author "dhkatz <dev@t5de.dev>"; never the user's real name or email. - Commits: author "dhkatz <dev@t5de.dev>"; never the user's real name or email.
- The user prioritizes backend performance (Go), and wants moderation centered on the hub's website. - The user prioritizes backend performance (Go), and wants moderation centered on the hub's website.
- IMVU session keys never leave the user's machine; the hub never asks for them. - IMVU session keys never leave the user's machine; the hub never asks for them.
## Notes
- Go, net/http, html/template (server-rendered; htmx 2.0.4 vendored in `internal/web/static`, CSP without inline
scripts or styles), pgx with plain SQL (`internal/store`, migrations embedded), coder/websocket.
- Dev: `go run ./cmd/t5de-hub -dev` (embedded Postgres on port 54321). Tests use port 54339 and a database each.
- The look: IMVU Classic's dialog palette (dark grays, gold buttons with black text), like T5DE's setup. Copy is
short and plain.
- Calls to IMVU (`internal/imvu`, its public user API) are rare and rate-limited: linking and age re-checks only.
+30 -3
View File
@@ -1,6 +1,33 @@
# t5de-hub # t5de-hub
T5DE's hub: accounts linked to IMVU accounts, the registry of community products and catalogs, presence, T5DE-hosted T5DE's hub: accounts linked to IMVU accounts, presence, and (to come) the registry of community products and
rooms, and the website with the moderation console. Part of T5DE (git.t5de.dev/t5de). catalogs, T5DE-hosted rooms, and the moderation console. Part of T5DE (git.t5de.dev/t5de). Design:
[docs/design.md](docs/design.md); what a room needs from a server: [docs/imq-rooms.md](docs/imq-rooms.md).
Design: [docs/design.md](docs/design.md) (draft). No code yet. ## What works
- **Accounts**: sign-up and sign-in (Argon2id passwords), sessions with CSRF protection. The first account is an
admin.
- **Linking IMVU accounts**: the person puts a code in the account's tagline; the hub reads it from IMVU's public
API. The most recent proof wins (an account linked elsewhere moves). Link caps per person (default 10).
- **Age**: an account counts as adult if IMVU says it's age verified or has Access Pass, checked by the hub.
- **Proxy sign-in**: OAuth 2.0 device codes (`t5de-proxy signin` shows a code to enter at `/device`); Ed25519 JWT
access tokens (keys at `/.well-known/jwks.json`) and rotating refresh tokens.
- **Presence**: signed-in proxies keep a WebSocket (`/api/v1/realtime`) telling the hub which IMVU account is in
use and the community products it wears. `/online` shows accounts that opted in, with the opt-in T5DE badge.
- **Console** (moderators): counts, who's online (live), people search, link caps, bans, roles, audit log.
## Running it
go run ./cmd/t5de-hub -dev
runs the hub at http://127.0.0.1:8080 with its own Postgres (downloaded once, kept in `t5de-hub-data/`). In
production: `t5de-hub -db postgres://... -public-url https://hub.t5de.dev -listen :8080 -data /var/lib/t5de-hub`
(the data directory holds the token signing key).
To point a T5DE proxy at it: `t5de-proxy signin -hub http://127.0.0.1:8080`.
## Tests
`go test ./...` runs everything, the website and API against a real Postgres (embedded, downloaded on first run
and cached; `T5DE_PG_CACHE` sets where) and a fake IMVU API.
+106
View File
@@ -0,0 +1,106 @@
// Command t5de-hub runs T5DE's hub: the website, the moderation console, and the API proxies sign in to.
//
// t5de-hub -db postgres://... -public-url https://hub.t5de.dev [-listen :8080] [-data dir]
// t5de-hub -dev runs its own Postgres in <data>/postgres (for development)
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"time"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
"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"
"git.t5de.dev/t5de/t5de-hub/internal/web"
)
func main() {
listen := flag.String("listen", "127.0.0.1:8080", "address to listen on")
dbURL := flag.String("db", os.Getenv("T5DE_HUB_DB"), "Postgres URL (or $T5DE_HUB_DB)")
publicURL := flag.String("public-url", "", "the hub's address as people reach it (default: http://<listen>)")
data := flag.String("data", "t5de-hub-data", "where the hub keeps its signing key (and, with -dev, its database)")
dev := flag.Bool("dev", false, "run a Postgres of its own in <data>/postgres, for development")
flag.Parse()
logger := log.New(os.Stderr, "", log.LstdFlags)
if err := run(logger, *listen, *dbURL, *publicURL, *data, *dev); err != nil {
logger.Fatal(err)
}
}
func run(logger *log.Logger, listen, dbURL, publicURL, data string, dev bool) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
if publicURL == "" {
publicURL = "http://" + strings.Replace(listen, "0.0.0.0", "localhost", 1)
}
publicURL = strings.TrimSuffix(publicURL, "/")
if dev {
pg, url, err := startDevPostgres(data)
if err != nil {
return fmt.Errorf("starting Postgres: %w", err)
}
defer pg.Stop()
dbURL = url
}
if dbURL == "" {
return errors.New("no database: give -db (or $T5DE_HUB_DB), or -dev")
}
st, err := store.Open(ctx, dbURL)
if err != nil {
return err
}
defer st.Close()
keys, err := token.LoadOrCreate(filepath.Join(data, "signing.key"), publicURL)
if err != nil {
return err
}
server := &web.Server{Store: st, IMVU: imvu.New(), Keys: keys, PublicURL: publicURL, Log: logger}
server.Realtime = realtime.New(web.Owners{Store: st}, logger)
handler, err := server.Handler()
if err != nil {
return err
}
srv := &http.Server{Addr: listen, Handler: handler, ReadHeaderTimeout: 10 * time.Second}
go func() {
<-ctx.Done()
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
srv.Shutdown(shutdown)
}()
logger.Printf("t5de-hub listening on %s (%s)", listen, publicURL)
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
// startDevPostgres runs Postgres from data/postgres (downloaded once into data/postgres-cache).
func startDevPostgres(data string) (*embeddedpostgres.EmbeddedPostgres, string, error) {
abs, err := filepath.Abs(data)
if err != nil {
return nil, "", err
}
const port = 54321
pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().
Port(port).Database("t5de_hub").
DataPath(filepath.Join(abs, "postgres", "data")).
RuntimePath(filepath.Join(abs, "postgres", "runtime")).
CachePath(filepath.Join(abs, "postgres-cache")).
StartTimeout(time.Minute))
if err := pg.Start(); err != nil {
return nil, "", err
}
return pg, fmt.Sprintf("postgres://postgres:postgres@localhost:%d/t5de_hub?sslmode=disable", port), nil
}
+9 -2
View File
@@ -279,8 +279,8 @@ One Go program in modules (split into services only if load requires it):
- **Moderation**: reports, actions, blocklist, audit log. - **Moderation**: reports, actions, blocklist, audit log.
- **Website and console** (**Decided**: in this repo): browse catalogs, products, creators, rooms, news; manage - **Website and console** (**Decided**: in this repo): browse catalogs, products, creators, rooms, news; manage
accounts (links, privacy, data export and deletion); catalog operators' screens; moderation console. accounts (links, privacy, data export and deletion); catalog operators' screens; moderation console.
**Decided**: server-rendered. Proposed: templ (Go templates) and htmx, built into the program: fast, cacheable at **Decided**: server-rendered. Built with Go's html/template (no code generation step) and htmx, embedded in the
Cloudflare, indexable, no JS build. How it looks comes from its CSS (a small design system of our own), not from program: fast, cacheable at Cloudflare, indexable, no JS build. How it looks comes from its CSS (a small design system of our own), not from
rendering on the server; small scripts where a page needs richer interaction (image galleries, drag and drop). rendering on the server; small scripts where a page needs richer interaction (image galleries, drag and drop).
### Data (sketch) ### Data (sketch)
@@ -333,6 +333,13 @@ The proxy streams and adds no waiting.
- Catalogs see addresses of those fetching adult files (with a token) and of uploaders. - Catalogs see addresses of those fetching adult files (with a token) and of uploaders.
- All stated in the setup and on the website. - All stated in the setup and on the website.
## Status
- Phase 1 (local packs) done: t5de-api, t5de-proxy `internal/products`.
- Phase 2 (hub identity) started: accounts, tagline linking, age, link caps, device sign-in, presence and badge,
console. Not yet: periodic age re-checks (only on demand), a sign-in UI in the client (the proxy's `signin`
command for now), deployment.
## Phases ## Phases
1. Spec (`t5de-api`); local product packs, removing community items from what IMVU sees, creator live preview. No 1. Spec (`t5de-api`); local product packs, removing community items from what IMVU sees, creator live preview. No
+21
View File
@@ -0,0 +1,21 @@
module git.t5de.dev/t5de/t5de-hub
go 1.27.0
require (
github.com/coder/websocket v1.8.15
github.com/fergusstrange/embedded-postgres v1.34.0
github.com/jackc/pgx/v5 v5.11.0
golang.org/x/crypto v0.58.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
golang.org/x/sync v0.24.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.43.0 // indirect
)
+40
View File
@@ -0,0 +1,40 @@
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fergusstrange/embedded-postgres v1.34.0 h1:c6RKhPKFsLVU+Tdxsx8q0UxCHsvZZ/iShAnljRBXs6s=
github.com/fergusstrange/embedded-postgres v1.34.0/go.mod h1:w0YvnCgf19o6tskInrOOACtnqfVlOvluz3hlNLY7tRk=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+144
View File
@@ -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
}
+30
View File
@@ -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)
}
}
+229
View File
@@ -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);
+529
View File
@@ -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
}
+117
View File
@@ -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)
}
+44
View File
@@ -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())
}
}
+278
View File
@@ -0,0 +1,278 @@
package web
import (
"crypto/rand"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"git.t5de.dev/t5de/t5de-hub/internal/imvu"
"git.t5de.dev/t5de/t5de-hub/internal/realtime"
"git.t5de.dev/t5de/t5de-hub/internal/store"
"git.t5de.dev/t5de/t5de-hub/internal/token"
)
func (s *Server) home(q *request) error {
data := map[string]any{"OnlineCount": len(s.visibleOnline(q))}
if q.person != nil {
links, err := s.Store.Links(s.ctx(q), q.person.ID)
if err != nil {
return err
}
data["Links"] = links
}
s.render(q, "home", data)
return nil
}
// linkView is a linked account on the account page.
type linkView struct {
store.Link
Online bool
Outfit []realtime.Item
}
func (s *Server) account(q *request) error {
ctx := s.ctx(q)
links, err := s.Store.Links(ctx, q.person.ID)
if err != nil {
return err
}
challenges, err := s.Store.Challenges(ctx, q.person.ID)
if err != nil {
return err
}
signins, err := s.Store.Signins(ctx, q.person.ID)
if err != nil {
return err
}
online := map[int64]realtime.Online{}
for _, o := range s.Realtime.Online() {
online[o.IMVUID] = o
}
views := make([]linkView, len(links))
for i, l := range links {
o, ok := online[l.IMVUID]
views[i] = linkView{Link: l, Online: ok, Outfit: o.Outfit}
}
s.render(q, "account", map[string]any{
"Links": views, "Challenges": challenges, "Signins": signins,
"CanLink": len(links) < q.person.LinkCap, "Error": q.r.URL.Query().Get("error"),
})
return nil
}
// linkCode is what goes in the tagline: "t5de-" and eight letters and digits (no look-alikes).
func linkCode() string {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
b := make([]byte, 8)
rand.Read(b)
for i := range b {
b[i] = alphabet[int(b[i])%len(alphabet)]
}
return "t5de-" + string(b[:4]) + "-" + string(b[4:])
}
func (s *Server) startLink(q *request) error {
username := strings.TrimSpace(q.r.PostFormValue("username"))
if !s.limiter.allow("imvu:"+strconv.FormatInt(q.person.ID, 10), 20, time.Hour) {
return q.redirect("/account?error="+queryEscape("Too many lookups. Try again in a while."), "")
}
u, err := s.IMVU.ByUsername(s.ctx(q), username)
if errors.Is(err, imvu.ErrNoSuchUser) {
return q.redirect("/account?error="+queryEscape(fmt.Sprintf("IMVU has no account named %q.", username)), "")
}
if err != nil {
s.Log.Printf("looking up %q on IMVU: %v", username, err)
return q.redirect("/account?error="+queryEscape("IMVU didn't answer. Try again in a moment."), "")
}
if l, err := s.Store.LinkByIMVUID(s.ctx(q), u.ID); err == nil && l.PersonID == q.person.ID {
return q.redirect("/account", u.Username+" is already linked.")
}
if err := s.Store.SetChallenge(s.ctx(q), q.person.ID, u.ID, u.Username, linkCode()); err != nil {
return err
}
return q.redirect("/account#link-"+strconv.FormatInt(u.ID, 10), "")
}
func imvuIDParam(q *request) (int64, error) {
id, err := strconv.ParseInt(q.r.PathValue("imvu"), 10, 64)
if err != nil || id <= 0 {
return 0, fail(http.StatusNotFound, "No such account.")
}
return id, nil
}
// checkLink reads the account's tagline from IMVU and links it if the code is there.
func (s *Server) checkLink(q *request) error {
ctx := s.ctx(q)
id, err := imvuIDParam(q)
if err != nil {
return err
}
c, err := s.Store.Challenge(ctx, q.person.ID, id)
if errors.Is(err, store.ErrNotFound) {
return q.redirect("/account?error="+queryEscape("That code expired. Start again."), "")
}
if err != nil {
return err
}
if !s.limiter.allow("imvu:"+strconv.FormatInt(q.person.ID, 10), 20, time.Hour) {
return q.redirect("/account?error="+queryEscape("Too many checks. Try again in a while."), "")
}
u, err := s.IMVU.ByID(ctx, id)
if err != nil {
s.Log.Printf("checking IMVU account %d: %v", id, err)
return q.redirect("/account?error="+queryEscape("IMVU didn't answer. Try again in a moment."), "")
}
if !strings.Contains(strings.ToLower(u.Tagline), strings.ToLower(c.Code)) {
return q.redirect("/account?error="+queryEscape(fmt.Sprintf("%s's tagline doesn't have the code yet. IMVU can take a minute to show a new tagline.", u.Username))+"#link-"+strconv.FormatInt(id, 10), "")
}
moved, err := s.Store.LinkAccount(ctx, q.person.ID, u.ID, u.Username, token.Random(16), u.AgeVerified, u.AccessPass)
if errors.Is(err, store.ErrLinkCap) {
return q.redirect("/account?error="+queryEscape(fmt.Sprintf("You can link up to %d IMVU accounts. Ask a moderator if you need more.", q.person.LinkCap)), "")
}
if err != nil {
return err
}
detail := map[string]any{"imvu": u.Username}
if moved != 0 {
detail["moved_from"] = moved
}
s.Store.Audit(ctx, q.person.ID, "link.add", strconv.FormatInt(u.ID, 10), detail)
return q.redirect("/account", fmt.Sprintf("Linked %s. You can take the code out of your tagline now.", u.Username))
}
func (s *Server) cancelLink(q *request) error {
id, err := imvuIDParam(q)
if err != nil {
return err
}
if err := s.Store.DeleteChallenge(s.ctx(q), q.person.ID, id); err != nil {
return err
}
return q.redirect("/account", "")
}
func (s *Server) ownLink(q *request) (*store.Link, error) {
id, err := imvuIDParam(q)
if err != nil {
return nil, err
}
l, err := s.Store.LinkByIMVUID(s.ctx(q), id)
if errors.Is(err, store.ErrNotFound) || err == nil && l.PersonID != q.person.ID {
return nil, fail(http.StatusNotFound, "That account isn't linked to you.")
}
return l, err
}
func (s *Server) linkSettings(q *request) error {
l, err := s.ownLink(q)
if err != nil {
return err
}
badge, presence := q.r.PostFormValue("badge") == "on", q.r.PostFormValue("presence") == "on"
if err := s.Store.SetLinkSettings(s.ctx(q), q.person.ID, l.IMVUID, badge, presence); err != nil {
return err
}
return q.redirect("/account", "Saved settings for "+l.Username+".")
}
// recheckLink reads the account's age status from IMVU again.
func (s *Server) recheckLink(q *request) error {
l, err := s.ownLink(q)
if err != nil {
return err
}
if !s.limiter.allow("imvu:"+strconv.FormatInt(q.person.ID, 10), 20, time.Hour) {
return q.redirect("/account?error="+queryEscape("Too many checks. Try again in a while."), "")
}
u, err := s.IMVU.ByID(s.ctx(q), l.IMVUID)
if err != nil {
return q.redirect("/account?error="+queryEscape("IMVU didn't answer. Try again in a moment."), "")
}
if err := s.Store.UpdateAge(s.ctx(q), l.IMVUID, u.Username, u.AgeVerified, u.AccessPass); err != nil {
return err
}
return q.redirect("/account", "Checked "+u.Username+" with IMVU.")
}
func (s *Server) unlink(q *request) error {
l, err := s.ownLink(q)
if err != nil {
return err
}
if err := s.Store.Unlink(s.ctx(q), q.person.ID, l.IMVUID); err != nil {
return err
}
s.Store.Audit(s.ctx(q), q.person.ID, "link.remove", strconv.FormatInt(l.IMVUID, 10), map[string]any{"imvu": l.Username})
return q.redirect("/account", "Unlinked "+l.Username+".")
}
func (s *Server) signOutProxies(q *request) error {
if err := s.Store.SignOutProxies(s.ctx(q), q.person.ID); err != nil {
return err
}
s.Realtime.Disconnect(q.person.ID)
return q.redirect("/account", "Signed out of T5DE on all your computers.")
}
func (s *Server) deviceForm(q *request) error {
s.render(q, "device", map[string]any{"Code": strings.ToUpper(q.r.URL.Query().Get("code"))})
return nil
}
func (s *Server) approveDevice(q *request) error {
code := normalizeUserCode(q.r.PostFormValue("code"))
err := s.Store.ApproveDeviceCode(s.ctx(q), code, q.person.ID)
if errors.Is(err, store.ErrNotFound) {
q.w.WriteHeader(http.StatusBadRequest)
s.render(q, "device", map[string]any{"Code": code, "Error": "That code isn't right, or it expired. Check the code T5DE shows."})
return nil
}
if err != nil {
return err
}
s.Store.Audit(s.ctx(q), q.person.ID, "proxy.signin", "", nil)
s.render(q, "device", map[string]any{"Approved": true})
return nil
}
// visibleOnline is who the viewer may see online in T5DE: accounts that opted in to presence (moderators see all).
func (s *Server) visibleOnline(q *request) []onlineView {
var out []onlineView
for _, o := range s.Realtime.Online() {
l, err := s.Store.LinkByIMVUID(s.ctx(q), o.IMVUID)
if err != nil {
continue
}
mod := q.person != nil && q.person.IsModerator()
if !l.ShowPresence && !mod {
continue
}
out = append(out, onlineView{Link: *l, Online: o, Hidden: !l.ShowPresence})
}
return out
}
type onlineView struct {
Link store.Link
Online realtime.Online
Hidden bool // shown to a moderator only
}
func (s *Server) online(q *request) error {
s.render(q, "online", map[string]any{"Online": s.visibleOnline(q)})
return nil
}
func (s *Server) onlineList(q *request) error {
s.fragment(q, "online", "online-list", map[string]any{"Online": s.visibleOnline(q)})
return nil
}
func queryEscape(s string) string { return url.QueryEscape(s) }
+216
View File
@@ -0,0 +1,216 @@
package web
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"git.t5de.dev/t5de/t5de-hub/internal/store"
"git.t5de.dev/t5de/t5de-hub/internal/token"
)
// The proxy signs in like a TV app (OAuth 2.0 device authorization, RFC 8628): it asks for a code, the person
// enters it on the website, and the proxy gets tokens: a short-lived access token (Ed25519 JWT) for the API, and
// a refresh token it trades for new ones.
const (
deviceLifetime = 10 * time.Minute
accessLifetime = 15 * time.Minute
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func apiError(w http.ResponseWriter, status int, code, description string) {
writeJSON(w, status, map[string]string{"error": code, "error_description": description})
}
// userCode is what a person types: 8 letters, no look-alikes, shown as XXXX-XXXX.
func userCode() string {
const alphabet = "BCDFGHJKLMNPQRSTVWXZ"
b := make([]byte, 8)
rand.Read(b)
for i := range b {
b[i] = alphabet[int(b[i])%len(alphabet)]
}
return string(b[:4]) + "-" + string(b[4:])
}
func normalizeUserCode(code string) string {
code = strings.ToUpper(strings.Map(func(r rune) rune {
if r == ' ' || r == '-' {
return -1
}
return r
}, code))
if len(code) == 8 {
code = code[:4] + "-" + code[4:]
}
return code
}
func (s *Server) deviceCode(w http.ResponseWriter, r *http.Request) {
if !s.limiter.allow("device:"+clientIP(r), 20, time.Hour) {
apiError(w, http.StatusTooManyRequests, "slow_down", "too many sign-ins from here")
return
}
device := token.Random(32)
var code string
for range 5 {
code = userCode()
err := s.Store.CreateDeviceCode(r.Context(), device, code, time.Now().Add(deviceLifetime))
if err == nil {
break
}
if !errors.Is(err, store.ErrTaken) {
s.Log.Printf("device code: %v", err)
apiError(w, http.StatusInternalServerError, "server_error", "")
return
}
}
writeJSON(w, http.StatusOK, map[string]any{
"device_code": device, "user_code": code, "expires_in": int(deviceLifetime.Seconds()), "interval": 3,
"verification_uri": s.PublicURL + "/device",
"verification_uri_complete": s.PublicURL + "/device?code=" + code,
})
}
func (s *Server) issue(ctx context.Context, w http.ResponseWriter, personID int64, refresh string) {
access, err := s.Keys.Issue("person:"+strconv.FormatInt(personID, 10), Audience, "proxy", accessLifetime)
if err != nil {
apiError(w, http.StatusInternalServerError, "server_error", "")
return
}
writeJSON(w, http.StatusOK, map[string]any{"access_token": access, "token_type": "Bearer",
"expires_in": int(accessLifetime.Seconds()), "refresh_token": refresh})
}
func (s *Server) deviceToken(w http.ResponseWriter, r *http.Request) {
status, personID, err := s.Store.ConsumeDeviceCode(r.Context(), r.PostFormValue("device_code"))
if err != nil {
s.Log.Printf("device token: %v", err)
apiError(w, http.StatusInternalServerError, "server_error", "")
return
}
switch status {
case store.DevicePending:
apiError(w, http.StatusBadRequest, "authorization_pending", "waiting for the code to be entered")
return
case store.DeviceGone:
apiError(w, http.StatusBadRequest, "expired_token", "the code expired or was used")
return
}
p, err := s.Store.PersonByID(r.Context(), personID)
if err != nil || p.Banned() {
apiError(w, http.StatusForbidden, "access_denied", "")
return
}
refresh := token.Random(32)
if err := s.Store.CreateRefreshToken(r.Context(), refresh, personID); err != nil {
apiError(w, http.StatusInternalServerError, "server_error", "")
return
}
s.issue(r.Context(), w, personID, refresh)
}
func (s *Server) refresh(w http.ResponseWriter, r *http.Request) {
replacement := token.Random(32)
personID, err := s.Store.RotateRefreshToken(r.Context(), r.PostFormValue("refresh_token"), replacement)
if errors.Is(err, store.ErrNotFound) {
apiError(w, http.StatusUnauthorized, "invalid_grant", "signed out")
return
}
if err != nil {
apiError(w, http.StatusInternalServerError, "server_error", "")
return
}
p, err := s.Store.PersonByID(r.Context(), personID)
if err != nil || p.Banned() {
apiError(w, http.StatusForbidden, "access_denied", "")
return
}
s.issue(r.Context(), w, personID, replacement)
}
// bearer is the person an API request's access token is for.
func (s *Server) bearer(r *http.Request) (*store.Person, error) {
auth := r.Header.Get("Authorization")
tok, ok := strings.CutPrefix(auth, "Bearer ")
if !ok {
return nil, errors.New("no token")
}
c, err := s.Keys.Check(tok, Audience)
if err != nil {
return nil, err
}
id, err := strconv.ParseInt(strings.TrimPrefix(c.Subject, "person:"), 10, 64)
if err != nil {
return nil, err
}
p, err := s.Store.PersonByID(r.Context(), id)
if err != nil || p.Banned() {
return nil, errors.New("no such person")
}
return p, nil
}
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
p, err := s.bearer(r)
if err != nil {
apiError(w, http.StatusUnauthorized, "invalid_token", "")
return
}
links, err := s.Store.Links(r.Context(), p.ID)
if err != nil {
apiError(w, http.StatusInternalServerError, "server_error", "")
return
}
type link struct {
IMVUID int64 `json:"imvu_id"`
Username string `json:"username"`
Adult bool `json:"adult"`
}
out := []link{}
for _, l := range links {
out = append(out, link{l.IMVUID, l.Username, l.Adult()})
}
writeJSON(w, http.StatusOK, map[string]any{"username": p.Username, "links": out})
}
func (s *Server) realtime(w http.ResponseWriter, r *http.Request) {
p, err := s.bearer(r)
if err != nil {
apiError(w, http.StatusUnauthorized, "invalid_token", "")
return
}
s.Realtime.Serve(w, r, p.ID)
}
func (s *Server) jwks(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=300")
w.Write(s.Keys.JWKS())
}
// LinkOwner implements realtime.Owners.
type Owners struct{ Store *store.Store }
func (o Owners) LinkOwner(ctx context.Context, imvuID int64) (int64, bool, error) {
l, err := o.Store.LinkByIMVUID(ctx, imvuID)
if errors.Is(err, store.ErrNotFound) {
return 0, false, nil
}
if err != nil {
return 0, false, err
}
return l.PersonID, true, nil
}
+172
View File
@@ -0,0 +1,172 @@
package web
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"unicode/utf8"
"golang.org/x/crypto/argon2"
"git.t5de.dev/t5de/t5de-hub/internal/store"
"git.t5de.dev/t5de/t5de-hub/internal/token"
)
// Passwords are hashed with Argon2id (OWASP's minimum: 19 MiB, 2 passes).
const (
argonTime = 2
argonMemory = 19 * 1024
argonThreads = 1
argonKeyLen = 32
)
func hashPassword(password string) string {
salt := make([]byte, 16)
rand.Read(salt)
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
enc := base64.RawStdEncoding.EncodeToString
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", argonMemory, argonTime, argonThreads, enc(salt), enc(key))
}
func checkPassword(hash, password string) bool {
parts := strings.Split(hash, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false
}
var m, t uint32
var p uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
return false
}
salt, err1 := base64.RawStdEncoding.DecodeString(parts[4])
want, err2 := base64.RawStdEncoding.DecodeString(parts[5])
if err1 != nil || err2 != nil {
return false
}
got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1
}
// dummyHash is checked against for unknown usernames, so sign-in takes as long either way.
var dummyHash = hashPassword("not a real password")
var usernamePattern = regexp.MustCompile(`^[A-Za-z0-9_]{3,32}$`)
func (s *Server) signupForm(q *request) error {
if q.person != nil {
return q.redirect("/account", "")
}
s.render(q, "signup", nil)
return nil
}
func (s *Server) signup(q *request) error {
username := strings.TrimSpace(q.r.PostFormValue("username"))
password := q.r.PostFormValue("password")
problem := ""
switch {
case !usernamePattern.MatchString(username):
problem = "Usernames are 3 to 32 letters, digits or underscores."
case utf8.RuneCountInString(password) < 10:
problem = "Use a password of at least 10 characters."
case len(password) > 256:
problem = "That password is too long."
case password != q.r.PostFormValue("password2"):
problem = "The passwords don't match."
case !s.limiter.allow("signup:"+clientIP(q.r), 5, time.Hour):
problem = "Too many new accounts from here. Try again later."
}
if problem != "" {
q.w.WriteHeader(http.StatusBadRequest)
s.render(q, "signup", map[string]any{"Error": problem, "Username": username})
return nil
}
p, err := s.Store.CreatePerson(s.ctx(q), username, hashPassword(password))
if errors.Is(err, store.ErrTaken) {
q.w.WriteHeader(http.StatusBadRequest)
s.render(q, "signup", map[string]any{"Error": "That username is taken.", "Username": username})
return nil
}
if err != nil {
return err
}
s.Store.Audit(s.ctx(q), p.ID, "account.create", p.Username, nil)
if err := s.startSession(q, p.ID); err != nil {
return err
}
return q.redirect("/account", "Welcome to T5DE! Link your IMVU account to get started.")
}
func (s *Server) signinForm(q *request) error {
if q.person != nil {
return q.redirect("/account", "")
}
s.render(q, "signin", map[string]any{"Next": safeNext(q.r.URL.Query().Get("next"))})
return nil
}
func (s *Server) signin(q *request) error {
username := strings.TrimSpace(q.r.PostFormValue("username"))
password := q.r.PostFormValue("password")
next := safeNext(q.r.PostFormValue("next"))
again := func(problem string) error {
q.w.WriteHeader(http.StatusBadRequest)
s.render(q, "signin", map[string]any{"Error": problem, "Username": username, "Next": next})
return nil
}
if !s.limiter.allow("signin:"+clientIP(q.r), 20, 15*time.Minute) || !s.limiter.allow("signin-user:"+strings.ToLower(username), 10, 15*time.Minute) {
return again("Too many tries. Wait a few minutes and try again.")
}
p, err := s.Store.PersonByUsername(s.ctx(q), username)
if errors.Is(err, store.ErrNotFound) {
checkPassword(dummyHash, password)
return again("Wrong username or password.")
}
if err != nil {
return err
}
if !checkPassword(p.PasswordHash, password) {
return again("Wrong username or password.")
}
if p.Banned() {
return again("This account is banned. " + p.BanReason)
}
if err := s.startSession(q, p.ID); err != nil {
return err
}
return q.redirect(next, "")
}
// safeNext keeps redirects after signing in on the hub.
func safeNext(next string) string {
if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") {
return "/account"
}
return next
}
const sessionLifetime = 30 * 24 * time.Hour
func (s *Server) startSession(q *request, personID int64) error {
tok, csrf := token.Random(32), token.Random(24)
if err := s.Store.CreateSession(s.ctx(q), tok, personID, csrf, time.Now().Add(sessionLifetime)); err != nil {
return err
}
http.SetCookie(q.w, &http.Cookie{Name: sessionCookie, Value: tok, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode,
Secure: strings.HasPrefix(s.PublicURL, "https://"), MaxAge: int(sessionLifetime.Seconds())})
return nil
}
func (s *Server) signout(q *request) error {
if q.token != "" {
s.Store.DeleteSession(s.ctx(q), q.token)
}
http.SetCookie(q.w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
return q.redirect("/", "Signed out.")
}
+141
View File
@@ -0,0 +1,141 @@
package web
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"git.t5de.dev/t5de/t5de-hub/internal/store"
)
// The moderation console: counts, who's online, people (search, link caps, bans, roles), and the audit log.
func (s *Server) console(q *request) error {
counts, err := s.Store.Counts(s.ctx(q))
if err != nil {
return err
}
log, err := s.Store.AuditLog(s.ctx(q), 50)
if err != nil {
return err
}
s.render(q, "console", map[string]any{"Counts": counts, "Connections": s.Realtime.Connections(),
"Online": s.visibleOnline(q), "Audit": log})
return nil
}
func (s *Server) consoleOnline(q *request) error {
s.fragment(q, "console", "console-online", map[string]any{"Online": s.visibleOnline(q), "Connections": s.Realtime.Connections()})
return nil
}
func (s *Server) consolePeople(q *request) error {
search := strings.TrimSpace(q.r.URL.Query().Get("q"))
people, err := s.Store.People(s.ctx(q), search, 100)
if err != nil {
return err
}
s.render(q, "console_people", map[string]any{"People": people, "Search": search})
return nil
}
func (s *Server) personParam(q *request) (*store.Person, error) {
id, err := strconv.ParseInt(q.r.PathValue("id"), 10, 64)
if err != nil {
return nil, fail(http.StatusNotFound, "No such person.")
}
p, err := s.Store.PersonByID(s.ctx(q), id)
if errors.Is(err, store.ErrNotFound) {
return nil, fail(http.StatusNotFound, "No such person.")
}
return p, err
}
func (s *Server) consolePerson(q *request) error {
p, err := s.personParam(q)
if err != nil {
return err
}
links, err := s.Store.Links(s.ctx(q), p.ID)
if err != nil {
return err
}
signins, err := s.Store.Signins(s.ctx(q), p.ID)
if err != nil {
return err
}
s.render(q, "console_person", map[string]any{"Subject": p, "Links": links, "Signins": signins})
return nil
}
func (s *Server) setCap(q *request) error {
p, err := s.personParam(q)
if err != nil {
return err
}
cap, err := strconv.Atoi(q.r.PostFormValue("cap"))
if err != nil || cap < 0 || cap > 1000 {
return fail(http.StatusBadRequest, "The link cap is a number from 0 to 1000.")
}
if err := s.Store.SetLinkCap(s.ctx(q), p.ID, cap); err != nil {
return err
}
s.Store.Audit(s.ctx(q), q.person.ID, "person.link_cap", p.Username, map[string]any{"from": p.LinkCap, "to": cap})
return q.redirect(fmt.Sprintf("/console/people/%d", p.ID), fmt.Sprintf("%s can link %d accounts.", p.Username, cap))
}
func (s *Server) ban(q *request) error {
p, err := s.personParam(q)
if err != nil {
return err
}
if p.ID == q.person.ID {
return fail(http.StatusBadRequest, "You can't ban yourself.")
}
if p.IsModerator() && !q.person.IsAdmin() {
return fail(http.StatusForbidden, "Only admins can ban moderators.")
}
reason := strings.TrimSpace(q.r.PostFormValue("reason"))
if reason == "" || len(reason) > 500 {
return fail(http.StatusBadRequest, "Give a reason (up to 500 characters); the person sees it.")
}
if err := s.Store.SetBanned(s.ctx(q), p.ID, true, reason); err != nil {
return err
}
s.Realtime.Disconnect(p.ID)
s.Store.Audit(s.ctx(q), q.person.ID, "person.ban", p.Username, map[string]any{"reason": reason})
return q.redirect(fmt.Sprintf("/console/people/%d", p.ID), "Banned "+p.Username+".")
}
func (s *Server) unban(q *request) error {
p, err := s.personParam(q)
if err != nil {
return err
}
if err := s.Store.SetBanned(s.ctx(q), p.ID, false, ""); err != nil {
return err
}
s.Store.Audit(s.ctx(q), q.person.ID, "person.unban", p.Username, nil)
return q.redirect(fmt.Sprintf("/console/people/%d", p.ID), "Unbanned "+p.Username+".")
}
func (s *Server) setRole(q *request) error {
p, err := s.personParam(q)
if err != nil {
return err
}
role := q.r.PostFormValue("role")
if role != "user" && role != "moderator" && role != "admin" {
return fail(http.StatusBadRequest, "No such role.")
}
if p.ID == q.person.ID {
return fail(http.StatusBadRequest, "Another admin has to change your role.")
}
if err := s.Store.SetRole(s.ctx(q), p.ID, role); err != nil {
return err
}
s.Store.Audit(s.ctx(q), q.person.ID, "person.role", p.Username, map[string]any{"from": p.Role, "to": role})
return q.redirect(fmt.Sprintf("/console/people/%d", p.ID), p.Username+" is now "+role+".")
}
+82
View File
@@ -0,0 +1,82 @@
package web
import (
"bytes"
"fmt"
"html/template"
"net/http"
"strings"
"time"
)
// pageNames are the templates with a full page each; "layout" wraps them. Fragments (for htmx) are defined inside
// page files and rendered on their own.
var pageNames = []string{"home", "signin", "signup", "account", "device", "online", "console", "console_people",
"console_person", "error"}
var funcs = template.FuncMap{
"ago": func(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%d min ago", int(d.Minutes()))
case d < 48*time.Hour:
return fmt.Sprintf("%d h ago", int(d.Hours()))
}
return t.Format("2 Jan 2006")
},
"date": func(t time.Time) string { return t.Format("2 Jan 2006 15:04") },
"upper": strings.ToUpper,
}
func (s *Server) parseTemplates() error {
s.pages = map[string]*template.Template{}
for _, name := range pageNames {
t, err := template.New("layout.html").Funcs(funcs).ParseFS(templateFiles, "templates/layout.html", "templates/"+name+".html")
if err != nil {
return err
}
s.pages[name] = t
}
return nil
}
// render shows a page in the layout. data is the page's own values; the layout gets the person and CSRF token.
func (s *Server) render(q *request, name string, data map[string]any) {
if data == nil {
data = map[string]any{}
}
data["Person"] = q.person
data["CSRF"] = q.csrf
data["Flash"] = q.flash
data["Page"] = name
var buf bytes.Buffer
if err := s.pages[name].ExecuteTemplate(&buf, "layout.html", data); err != nil {
s.Log.Printf("rendering %s: %v", name, err)
http.Error(q.w, "Something went wrong on our side.", http.StatusInternalServerError)
return
}
q.w.Header().Set("Content-Type", "text/html; charset=utf-8")
q.w.Header().Set("Cache-Control", "no-store")
buf.WriteTo(q.w)
}
// fragment shows one template from a page's file, without the layout (htmx swaps it in).
func (s *Server) fragment(q *request, page, name string, data map[string]any) {
if data == nil {
data = map[string]any{}
}
data["Person"] = q.person
data["CSRF"] = q.csrf
var buf bytes.Buffer
if err := s.pages[page].ExecuteTemplate(&buf, name, data); err != nil {
s.Log.Printf("rendering %s/%s: %v", page, name, err)
http.Error(q.w, "Something went wrong on our side.", http.StatusInternalServerError)
return
}
q.w.Header().Set("Content-Type", "text/html; charset=utf-8")
q.w.Header().Set("Cache-Control", "no-store")
buf.WriteTo(q.w)
}
+267
View File
@@ -0,0 +1,267 @@
// Package web is the hub's website (server-rendered pages, htmx for the live parts) and its API for proxies.
package web
import (
"context"
"crypto/subtle"
"embed"
"errors"
"html/template"
"io/fs"
"log"
"net"
"net/http"
"strings"
"sync"
"time"
"git.t5de.dev/t5de/t5de-hub/internal/imvu"
"git.t5de.dev/t5de/t5de-hub/internal/realtime"
"git.t5de.dev/t5de/t5de-hub/internal/store"
"git.t5de.dev/t5de/t5de-hub/internal/token"
)
//go:embed templates/*.html
var templateFiles embed.FS
//go:embed static
var staticFiles embed.FS
// Audience is the hub's own API, in its access tokens.
const Audience = "t5de-hub"
// Server is the website and API.
type Server struct {
Store *store.Store
IMVU *imvu.Client
Keys *token.Keys
Realtime *realtime.Hub
PublicURL string // e.g. https://hub.t5de.dev, for links shown to proxies
Log *log.Logger
pages map[string]*template.Template
limiter *limiter
}
// Handler is the hub's HTTP handler.
func (s *Server) Handler() (http.Handler, error) {
if err := s.parseTemplates(); err != nil {
return nil, err
}
s.limiter = &limiter{hits: map[string][]time.Time{}}
mux := http.NewServeMux()
static, _ := fs.Sub(staticFiles, "static")
mux.Handle("GET /static/", http.StripPrefix("/static/", cacheStatic(http.FileServerFS(static))))
mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/static/favicon.svg", http.StatusMovedPermanently)
})
mux.HandleFunc("GET /{$}", s.page(s.home))
mux.HandleFunc("GET /signup", s.page(s.signupForm))
mux.HandleFunc("POST /signup", s.page(s.signup))
mux.HandleFunc("GET /signin", s.page(s.signinForm))
mux.HandleFunc("POST /signin", s.page(s.signin))
mux.HandleFunc("POST /signout", s.page(s.signout))
mux.HandleFunc("GET /online", s.page(s.online))
mux.HandleFunc("GET /online/list", s.page(s.onlineList))
mux.HandleFunc("GET /account", s.page(s.signedIn(s.account)))
mux.HandleFunc("POST /account/link", s.page(s.signedIn(s.startLink)))
mux.HandleFunc("POST /account/link/{imvu}/check", s.page(s.signedIn(s.checkLink)))
mux.HandleFunc("POST /account/link/{imvu}/cancel", s.page(s.signedIn(s.cancelLink)))
mux.HandleFunc("POST /account/links/{imvu}/settings", s.page(s.signedIn(s.linkSettings)))
mux.HandleFunc("POST /account/links/{imvu}/recheck", s.page(s.signedIn(s.recheckLink)))
mux.HandleFunc("POST /account/links/{imvu}/unlink", s.page(s.signedIn(s.unlink)))
mux.HandleFunc("POST /account/proxies/signout", s.page(s.signedIn(s.signOutProxies)))
mux.HandleFunc("GET /device", s.page(s.signedIn(s.deviceForm)))
mux.HandleFunc("POST /device", s.page(s.signedIn(s.approveDevice)))
mux.HandleFunc("GET /console", s.page(s.moderator(s.console)))
mux.HandleFunc("GET /console/online", s.page(s.moderator(s.consoleOnline)))
mux.HandleFunc("GET /console/people", s.page(s.moderator(s.consolePeople)))
mux.HandleFunc("GET /console/people/{id}", s.page(s.moderator(s.consolePerson)))
mux.HandleFunc("POST /console/people/{id}/cap", s.page(s.moderator(s.setCap)))
mux.HandleFunc("POST /console/people/{id}/ban", s.page(s.moderator(s.ban)))
mux.HandleFunc("POST /console/people/{id}/unban", s.page(s.moderator(s.unban)))
mux.HandleFunc("POST /console/people/{id}/role", s.page(s.admin(s.setRole)))
mux.HandleFunc("GET /.well-known/jwks.json", s.jwks)
mux.HandleFunc("POST /api/v1/device/code", s.deviceCode)
mux.HandleFunc("POST /api/v1/device/token", s.deviceToken)
mux.HandleFunc("POST /api/v1/token/refresh", s.refresh)
mux.HandleFunc("GET /api/v1/me", s.me)
mux.HandleFunc("GET /api/v1/realtime", s.realtime)
return securityHeaders(mux), nil
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' https: data:; style-src 'self'; script-src 'self'; "+
"connect-src 'self'; frame-ancestors 'none'; form-action 'self'; base-uri 'none'; object-src 'none'")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "same-origin")
h.Set("X-Frame-Options", "DENY")
next.ServeHTTP(w, r)
})
}
func cacheStatic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=3600")
next.ServeHTTP(w, r)
})
}
// request is one page request: who's asking, and what the page shows.
type request struct {
w http.ResponseWriter
r *http.Request
person *store.Person // nil when signed out
csrf string
token string // session token
flash string
}
type pageFunc func(q *request) error
// errStatus is an error with the HTTP status to answer with, shown to the person.
type errStatus struct {
status int
message string
}
func (e errStatus) Error() string { return e.message }
func fail(status int, message string) error { return errStatus{status, message} }
const sessionCookie = "t5de_session"
// page wraps a page handler: it finds the session, checks CSRF on POSTs, and shows errors.
func (s *Server) page(fn pageFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := &request{w: w, r: r, flash: r.URL.Query().Get("done")}
if c, err := r.Cookie(sessionCookie); err == nil {
p, csrf, err := s.Store.Session(r.Context(), c.Value)
if err == nil && !p.Banned() {
q.person, q.csrf, q.token = p, csrf, c.Value
}
}
if r.Method == http.MethodPost {
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
sent := r.Header.Get("X-CSRF-Token")
if sent == "" {
sent = r.PostFormValue("csrf")
}
// Signed in, a form must carry the session's token. Signed out (sign-in and sign-up), browsers' same-site
// checks apply: the form must come from the hub itself.
if q.person != nil && subtle.ConstantTimeCompare([]byte(sent), []byte(q.csrf)) != 1 ||
q.person == nil && !sameOrigin(r) {
s.showError(q, fail(http.StatusForbidden, "That form expired. Reload the page and try again."))
return
}
}
if err := fn(q); err != nil {
s.showError(q, err)
}
}
}
func sameOrigin(r *http.Request) bool {
if site := r.Header.Get("Sec-Fetch-Site"); site != "" {
return site == "same-origin" || site == "none"
}
origin := r.Header.Get("Origin")
return origin == "" || strings.TrimPrefix(strings.TrimPrefix(origin, "https://"), "http://") == r.Host
}
func (s *Server) signedIn(fn pageFunc) pageFunc {
return func(q *request) error {
if q.person == nil {
http.Redirect(q.w, q.r, "/signin?next="+q.r.URL.RequestURI(), http.StatusSeeOther)
return nil
}
return fn(q)
}
}
func (s *Server) moderator(fn pageFunc) pageFunc {
return s.signedIn(func(q *request) error {
if !q.person.IsModerator() {
return fail(http.StatusForbidden, "Only moderators can see this.")
}
return fn(q)
})
}
func (s *Server) admin(fn pageFunc) pageFunc {
return s.signedIn(func(q *request) error {
if !q.person.IsAdmin() {
return fail(http.StatusForbidden, "Only admins can do this.")
}
return fn(q)
})
}
func (s *Server) showError(q *request, err error) {
var e errStatus
if !errors.As(err, &e) {
s.Log.Printf("%s %s: %v", q.r.Method, q.r.URL.Path, err)
e = errStatus{http.StatusInternalServerError, "Something went wrong on our side. Try again in a moment."}
}
q.w.WriteHeader(e.status)
s.render(q, "error", map[string]any{"Message": e.message})
}
// redirect sends the browser on after a form, with a short note to show.
func (q *request) redirect(path, done string) error {
if done != "" {
sep := "?"
if strings.Contains(path, "?") {
sep = "&"
}
path += sep + "done=" + template.URLQueryEscaper(done)
}
http.Redirect(q.w, q.r, path, http.StatusSeeOther)
return nil
}
func clientIP(r *http.Request) string {
if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
return ip
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// limiter allows a number of attempts per key in a window (sign-ins, sign-ups, IMVU lookups).
type limiter struct {
mu sync.Mutex
hits map[string][]time.Time
}
func (l *limiter) allow(key string, n int, window time.Duration) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
recent := l.hits[key][:0]
for _, t := range l.hits[key] {
if now.Sub(t) < window {
recent = append(recent, t)
}
}
if len(recent) >= n {
l.hits[key] = recent
return false
}
l.hits[key] = append(recent, now)
if len(l.hits) > 100000 { // forget everything rather than grow without end
l.hits = map[string][]time.Time{}
}
return true
}
func (s *Server) ctx(q *request) context.Context { return q.r.Context() }
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ffe795"/><stop offset="1" stop-color="#ffd200"/></linearGradient></defs><rect width="64" height="64" rx="14" fill="url(#g)"/><text x="32" y="43" font-family="Segoe UI, Arial, sans-serif" font-size="27" font-weight="800" text-anchor="middle" fill="#000">T5</text></svg>

After

Width:  |  Height:  |  Size: 416 B

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