{{.Question.Title}}
+ +{{.Question.Body}}
+ {{if isAdmin .User}} + + {{end}} +diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..2214178 --- /dev/null +++ b/.air.toml @@ -0,0 +1,33 @@ +# Hot reload for local development: air +# https://github.com/air-verse/air + +root = "." +tmp_dir = "tmp" + +[build] + cmd = "go build -o ./tmp/server ./cmd/server" + bin = "./tmp/server" + full_bin = "./tmp/server" + include_ext = ["go", "html", "css", "js", "sql"] + exclude_dir = ["tmp", "vendor", "testdata", "bin"] + exclude_file = [] + exclude_regex = ["_test\\.go"] + exclude_unchanged = true + follow_symlink = false + delay = 500 + stop_on_error = true + send_interrupt = true + kill_delay = "1s" + +[log] + time = false + main_only = false + +[color] + main = "magenta" + watcher = "cyan" + build = "yellow" + runner = "green" + +[misc] + clean_on_exit = true diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..71f6ffc --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Local listen address (ignored when PORT is set, e.g. on App Platform) +LISTEN=:8080 +# Required: PlanetScale Postgres URI (port 5432 so the app can create tables on boot). +# Switch to 6432 (PgBouncer) later if you need pooling. +DATABASE_URL=postgresql://user:password@host.example.com:5432/postgres?sslmode=verify-full +# Required for integration tests (do not point at the runtime DATABASE_URL). +# TEST_DATABASE_URL=postgresql://user:password@host.example.com:5432/plumber_test?sslmode=verify-full +# One-time first-admin bootstrap: registrant must also POST setup_secret matching this value, +# and only while no admin exists yet. Leave unset after bootstrap. Prefer a long random string. +# ADMIN_SETUP_SECRET= +# When PORT is set (App Platform), cookies are Secure by default; SECURE_COOKIE=0 is rejected. +# Locally, set to 1 when serving over HTTPS: +SECURE_COOKIE=0 +# Comma-separated CIDRs of reverse proxies allowed to set X-Forwarded-For +# (direct peer must match). Leave unset to ignore XFF and use RemoteAddr. +# TRUSTED_PROXY_CIDRS=10.0.0.0/8,192.168.0.0/16 +# DigitalOcean Spaces (profile avatars). Leave unset to disable uploads. +# SPACES_KEY= +# SPACES_SECRET= +# SPACES_REGION=nyc3 +# SPACES_BUCKET=your-bucket +# SPACES_ENDPOINT=https://nyc3.digitaloceanspaces.com +# SPACES_CDN_BASE=https://your-bucket.nyc3.cdn.digitaloceanspaces.com diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6df0713 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,17 @@ +name: CI + +on: + pull_request: + push: + branches: [master, main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Test + run: go test -race ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29d4243 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/bin/ +/tmp/ +.env +*.exe diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..deeaa84 --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +.PHONY: sqlc +sqlc: + sqlc generate + +.PHONY: sqlc-check +sqlc-check: + sqlc diff diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..ec20f60 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,150 @@ +package main + +import ( + "context" + "database/sql" + "errors" + "log" + "net" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/joho/godotenv" + + "plumber" + "plumber/internal/blob" + "plumber/internal/store" + "plumber/internal/web" +) + +func main() { + _ = godotenv.Load() + + db, sessions := openDB() + defer db.Close() + defer sessions.Close() + + uploader := blob.FromEnv() + handler := newHandler(db, sessions, uploader) + run(&http.Server{ + Addr: listenAddr(), + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 90 * time.Second, + }) +} + +func openDB() (*sql.DB, *store.SessionStore) { + databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL")) + if databaseURL == "" { + log.Fatal("DATABASE_URL is required") + } + db, sessions, err := store.OpenPostgres(databaseURL, plumber.SchemaSQL) + if err != nil { + log.Fatalf("database: %v", err) + } + log.Printf("database: postgres") + return db, sessions +} + +func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader) http.Handler { + srv, err := web.New(store.NewPostgres(db), sessions.Store(), plumber.TemplateFS, plumber.StaticFS, web.Config{ + AdminSetupSecret: strings.TrimSpace(os.Getenv("ADMIN_SETUP_SECRET")), + SecureCookie: secureCookieFromEnv(), + TrustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDRS")), + Blob: uploader, + }) + if err != nil { + log.Fatalf("server: %v", err) + } + return srv.Handler() +} + +func parseTrustedProxies(raw string) []*net.IPNet { + var out []*net.IPNet + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + _, n, err := net.ParseCIDR(part) + if err != nil { + log.Fatalf("TRUSTED_PROXY_CIDRS: bad CIDR %q: %v", part, err) + } + out = append(out, n) + } + return out +} + +// secureCookieFromEnv defaults to secure when PORT is set (PaaS/production) +// and refuses an explicit disable in that environment. +func secureCookieFromEnv() bool { + v := strings.TrimSpace(os.Getenv("SECURE_COOKIE")) + if strings.TrimSpace(os.Getenv("PORT")) != "" { + if v == "0" { + log.Fatal("SECURE_COOKIE=0 is not allowed when PORT is set") + } + return true + } + return v == "1" +} + +func run(httpSrv *http.Server) { + errCh := make(chan error, 1) + go func() { + log.Printf("listening on %s", httpSrv.Addr) + errCh <- httpSrv.ListenAndServe() + }() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + + select { + case err := <-errCh: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatal(err) + } + case sig := <-sigCh: + log.Printf("shutdown signal: %v", sig) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + err := httpSrv.Shutdown(ctx) + cancel() + if err != nil { + log.Printf("shutdown: %v", err) + _ = httpSrv.Close() + } + select { + case err := <-errCh: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Printf("server exit: %v", err) + } + case <-time.After(3 * time.Second): + log.Printf("server exit: timed out waiting for ListenAndServe") + } + } +} + +// listenAddr prefers PORT (App Platform / PaaS), then LISTEN, then :8080. +func listenAddr() string { + if p := strings.TrimSpace(os.Getenv("PORT")); p != "" { + if strings.HasPrefix(p, ":") { + return p + } + return ":" + p + } + return env("LISTEN", ":8080") +} + +func env(key, fallback string) string { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return fallback + } + return v +} diff --git a/db/queries/answers.sql b/db/queries/answers.sql new file mode 100644 index 0000000..bd1c59d --- /dev/null +++ b/db/queries/answers.sql @@ -0,0 +1,11 @@ +-- name: UpsertAnswer :exec +INSERT INTO answers (question_id, author_id, body, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (question_id) DO UPDATE +SET body = excluded.body, author_id = excluded.author_id, updated_at = excluded.updated_at; + +-- name: GetAnswer :one +SELECT a.question_id, a.author_id, u.name AS author_name, a.body, a.created_at, a.updated_at +FROM answers a +JOIN users u ON u.id = a.author_id +WHERE a.question_id = $1; diff --git a/db/queries/questions.sql b/db/queries/questions.sql new file mode 100644 index 0000000..70d62cb --- /dev/null +++ b/db/queries/questions.sql @@ -0,0 +1,62 @@ +-- name: CreateQuestion :exec +INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at) +VALUES ($1, $2, $3, $4, $5, $6, 0, $7); + +-- name: HideQuestion :exec +UPDATE questions +SET hidden = 1 +WHERE id = $1; + +-- name: ListHunt :many +SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, + COALESCE(SUM(v.value), 0)::bigint AS score, + CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered, + COALESCE(( + SELECT votes.value FROM votes + WHERE votes.user_id = sqlc.arg(viewer_id) AND votes.question_id = q.id + ), 0)::bigint AS user_vote +FROM questions q +JOIN users u ON u.id = q.author_id +LEFT JOIN votes v ON v.question_id = q.id +LEFT JOIN answers a ON a.question_id = q.id +WHERE q.hunt_date = sqlc.arg(hunt_date) AND q.hidden = 0 +GROUP BY q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, a.question_id +ORDER BY score DESC, q.created_at ASC +LIMIT sqlc.arg(row_limit); + +-- name: GetQuestion :one +SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, + COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score, + CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered, + COALESCE(( + SELECT votes.value FROM votes + WHERE votes.user_id = sqlc.arg(viewer_id) AND votes.question_id = q.id + ), 0)::bigint AS user_vote +FROM questions q +JOIN users u ON u.id = q.author_id +LEFT JOIN answers a ON a.question_id = q.id +WHERE q.id = sqlc.arg(id); + +-- name: ListQuestionsByAuthor :many +SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, + COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score, + CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered, + 0::bigint AS user_vote +FROM questions q +JOIN users u ON u.id = q.author_id +LEFT JOIN answers a ON a.question_id = q.id +WHERE q.author_id = sqlc.arg(author_id) AND q.hidden = 0 +ORDER BY q.created_at DESC +LIMIT sqlc.arg(row_limit); + +-- name: ListQuestionsAnsweredBy :many +SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, + COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score, + 1::bigint AS answered, + 0::bigint AS user_vote +FROM answers ans +JOIN questions q ON q.id = ans.question_id +JOIN users u ON u.id = q.author_id +WHERE ans.author_id = sqlc.arg(admin_id) AND q.hidden = 0 +ORDER BY ans.updated_at DESC +LIMIT sqlc.arg(row_limit); diff --git a/db/queries/sessions.sql b/db/queries/sessions.sql new file mode 100644 index 0000000..5f8e71a --- /dev/null +++ b/db/queries/sessions.sql @@ -0,0 +1,18 @@ +-- name: GetSession :one +SELECT data +FROM sessions +WHERE token = $1 AND expiry > now(); + +-- name: UpsertSession :exec +INSERT INTO sessions (token, data, expiry) +VALUES ($1, $2, $3) +ON CONFLICT (token) DO UPDATE +SET data = excluded.data, expiry = excluded.expiry; + +-- name: DeleteSession :exec +DELETE FROM sessions +WHERE token = $1; + +-- name: DeleteExpiredSessions :execrows +DELETE FROM sessions +WHERE expiry <= now(); diff --git a/db/queries/users.sql b/db/queries/users.sql new file mode 100644 index 0000000..02e4271 --- /dev/null +++ b/db/queries/users.sql @@ -0,0 +1,54 @@ +-- name: CreateUser :exec +INSERT INTO users (id, username, name, password_hash, role, avatar_url, state, created_at) +VALUES ($1, $2, $3, $4, $5, '', '', $6); + +-- name: GetUserByID :one +SELECT id, username, name, role, avatar_url, state, created_at +FROM users +WHERE id = $1; + +-- name: GetUserByUsername :one +SELECT id, username, name, role, avatar_url, state, created_at, password_hash +FROM users +WHERE username = $1; + +-- name: ListUsers :many +SELECT id, username, name, role, avatar_url, state, created_at +FROM users +WHERE ( + sqlc.arg(search) = '' + OR username ILIKE '%' || sqlc.arg(search) || '%' + OR name ILIKE '%' || sqlc.arg(search) || '%' +) +AND ( + sqlc.arg(cursor_created) = '' + OR created_at < sqlc.arg(cursor_created) + OR (created_at = sqlc.arg(cursor_created) AND id < sqlc.arg(cursor_id)) +) +ORDER BY created_at DESC, id DESC +LIMIT sqlc.arg(row_limit); + +-- name: CountAdmins :one +SELECT COUNT(*)::bigint AS count +FROM users +WHERE role = $1; + +-- name: GetUserRole :one +SELECT role +FROM users +WHERE id = $1; + +-- name: UpdateUserRole :execresult +UPDATE users +SET role = $1 +WHERE id = $2; + +-- name: UpdateUserState :exec +UPDATE users +SET state = $1 +WHERE id = $2; + +-- name: UpdateUserStateAndAvatar :exec +UPDATE users +SET state = $1, avatar_url = $2 +WHERE id = $3; diff --git a/db/queries/votes.sql b/db/queries/votes.sql new file mode 100644 index 0000000..a8917ba --- /dev/null +++ b/db/queries/votes.sql @@ -0,0 +1,24 @@ +-- name: GetVote :one +SELECT value +FROM votes +WHERE user_id = $1 AND question_id = $2; + +-- name: QuestionIsVisible :one +SELECT EXISTS( + SELECT 1 FROM questions WHERE id = $1 AND hidden = 0 +)::bool; + +-- name: DeleteVote :exec +DELETE FROM votes +WHERE user_id = $1 AND question_id = $2; + +-- name: UpsertVoteOnVisible :execrows +INSERT INTO votes (user_id, question_id, value) +SELECT $1, $2, $3 +FROM questions q +WHERE q.id = $2 AND q.hidden = 0 +ON CONFLICT (user_id, question_id) DO UPDATE +SET value = excluded.value +WHERE EXISTS ( + SELECT 1 FROM questions q2 WHERE q2.id = excluded.question_id AND q2.hidden = 0 +); diff --git a/embed.go b/embed.go new file mode 100644 index 0000000..d74be85 --- /dev/null +++ b/embed.go @@ -0,0 +1,12 @@ +package plumber + +import "embed" + +//go:embed templates/*.html templates/partials/*.html +var TemplateFS embed.FS + +//go:embed static +var StaticFS embed.FS + +//go:embed schema.sql +var SchemaSQL string diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ccba0df --- /dev/null +++ b/go.mod @@ -0,0 +1,33 @@ +module plumber + +go 1.25.0 + +require ( + github.com/alexedwards/scs/v2 v2.9.0 + github.com/aws/aws-sdk-go-v2 v1.43.7 + github.com/aws/aws-sdk-go-v2/credentials v1.19.37 + github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3 + github.com/go-chi/chi/v5 v5.3.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.10.0 + github.com/joho/godotenv v1.5.1 + golang.org/x/crypto v0.55.0 + golang.org/x/image v0.45.0 +) + +require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.38 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.38 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.39 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.31 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.38 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.39 // indirect + github.com/aws/smithy-go v1.27.8 // indirect + 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 + golang.org/x/sync v0.22.0 // indirect + golang.org/x/text v0.41.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0a8c3d7 --- /dev/null +++ b/go.sum @@ -0,0 +1,62 @@ +github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90= +github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= +github.com/aws/aws-sdk-go-v2 v1.43.7 h1:msCzvkeYJA9ehbV8mRRmkZLo/zJg/+yDVLNtflg83hQ= +github.com/aws/aws-sdk-go-v2 v1.43.7/go.mod h1:tXpPM+v0D1lndmga+HqqLDIzUFJlEeR21aspVklHF00= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18 h1:LAfOuhAH331fmOjTQpAaOlH+Ftn7RzSDJ2VFwjdMMy4= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18/go.mod h1:4e5xhuXHx1e4U9EthvbPP1r/DIMp5c2823OL8karzcM= +github.com/aws/aws-sdk-go-v2/credentials v1.19.37 h1:FJ8Iz4/xISMB/rwLlgfWujfGDFWr0oneQgtA6KPcYLY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.37/go.mod h1:Q6pWOgVUp49x4g5QVi29wHofUoICnZ+Zq4jHbRN/7ec= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.38 h1:MBMg0zJ6i4TkAJ0dVFLKKn2cOkY6FkicmUDM67BRr6g= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.38/go.mod h1:9MWuJbyiUyj6eA7W1/zm1zuePDPSB3g+xcgRQeMWsXc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.38 h1:lHm4jPf3k1Lz5ZWc+Vcn3MKVwym+26kWCba9FkJ4f0Y= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.38/go.mod h1:Rn+P2XR+FbyZzjmWKjg/KUZNxmGfr5oZwh5jQiE+CzI= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.39 h1:vo4xvMRs/F6h1E52qsgLqCQgWIQXgIJUauG6rlZEh4U= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.39/go.mod h1:jB03R1ij/A+OE2e1dz6vgj076gd7vlYcfstAzj3HcnU= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 h1:OvYZOB3qA6zvfdRFiRFRzVSiElMYrz3GdntkXZxlp1o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17/go.mod h1:JgR/2Ew50ACfIWau1oeMRX59tMtC0kM+PYQGEaT04cY= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.31 h1:uZOinZb+h7lZw8IYzP1z1IuEnueB76/EFkcf/fEW4Ag= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.31/go.mod h1:NRtwAM/p5VRt03TlEUs0pH3TeWamWdf4YyJpSrzPYLc= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.38 h1:H/5TI1jqaHsNoDQ60UwvPvJBg4GURkinXI3Qga29t2w= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.38/go.mod h1:PTVFf+XH++7NJOky+RLBYQx0QA5NcaeEYFQ2fsi0nwo= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.39 h1:HLPAVrlLDaN2boN0xJx7MgaQDNEO3Q+c9L6kl/8m47Q= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.39/go.mod h1:Pg/dVfsNkm1hsIDK/gMvCKtmyNfNTV12mrgHqVE/6Oo= +github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3 h1:IKoCZqfWfZzSBi16QFQ+QcbQ3LRQ7QgB1S5tDAyPBQQ= +github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3/go.mod h1:RBpRcXiM4s2pOInVs32GsBonnje+fiAj4mcrStRmlCA= +github.com/aws/smithy-go v1.27.8 h1:FR0dxZfIlV7Z8eh2iHfIofdunw382XsDV3Mxt9nUvRY= +github.com/aws/smithy-go v1.27.8/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +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/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= +github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +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= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= +golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +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= diff --git a/internal/blob/spaces.go b/internal/blob/spaces.go new file mode 100644 index 0000000..0493d01 --- /dev/null +++ b/internal/blob/spaces.go @@ -0,0 +1,146 @@ +package blob + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" +) + +// Uploader stores public avatar objects. +type Uploader interface { + Enabled() bool + Upload(ctx context.Context, obj FileUpload) (publicURL string, err error) + Delete(ctx context.Context, key string) error +} + +// FileUpload is a file body to store (e.g. an avatar). +type FileUpload struct { + Key string + Body io.Reader + ContentType string + Size int64 +} + +// Disabled is a no-op uploader used when Spaces is not configured. +type Disabled struct{} + +// SpacesConfig holds DigitalOcean Spaces settings. +type SpacesConfig struct { + Key string + Secret string + Region string + Bucket string + Endpoint string // e.g. https://nyc3.digitaloceanspaces.com + CDNBase string // optional public base URL without trailing slash +} + +type spaces struct { + client *s3.Client + cfg SpacesConfig +} + +func (Disabled) Enabled() bool { return false } + +func (Disabled) Upload(context.Context, FileUpload) (string, error) { + return "", fmt.Errorf("avatar uploads are not configured") +} + +func (Disabled) Delete(context.Context, string) error { return nil } + +// FromEnv builds an Uploader from SPACES_* environment variables. +func FromEnv() Uploader { + return NewSpaces(SpacesConfig{ + Key: os.Getenv("SPACES_KEY"), + Secret: os.Getenv("SPACES_SECRET"), + Region: os.Getenv("SPACES_REGION"), + Bucket: os.Getenv("SPACES_BUCKET"), + Endpoint: os.Getenv("SPACES_ENDPOINT"), + CDNBase: os.Getenv("SPACES_CDN_BASE"), + }) +} + +// NewSpaces returns an Uploader when required env is present; otherwise Disabled. +func NewSpaces(cfg SpacesConfig) Uploader { + cfg.Key = strings.TrimSpace(cfg.Key) + cfg.Secret = strings.TrimSpace(cfg.Secret) + cfg.Region = strings.TrimSpace(cfg.Region) + cfg.Bucket = strings.TrimSpace(cfg.Bucket) + cfg.Endpoint = strings.TrimSpace(cfg.Endpoint) + cfg.CDNBase = strings.TrimRight(strings.TrimSpace(cfg.CDNBase), "/") + if cfg.Key == "" || cfg.Secret == "" || cfg.Region == "" || cfg.Bucket == "" || cfg.Endpoint == "" { + return Disabled{} + } + client := s3.New(s3.Options{ + Region: cfg.Region, + Credentials: credentials.NewStaticCredentialsProvider(cfg.Key, cfg.Secret, ""), + BaseEndpoint: aws.String(cfg.Endpoint), + }) + return &spaces{client: client, cfg: cfg} +} + +func (s *spaces) Enabled() bool { return true } + +func (s *spaces) Upload(ctx context.Context, obj FileUpload) (string, error) { + key := strings.TrimPrefix(obj.Key, "/") + input := &s3.PutObjectInput{ + Bucket: aws.String(s.cfg.Bucket), + Key: aws.String(key), + Body: obj.Body, + ContentType: aws.String(obj.ContentType), + ACL: types.ObjectCannedACLPublicRead, + } + if obj.Size > 0 { + input.ContentLength = aws.Int64(obj.Size) + } + if _, err := s.client.PutObject(ctx, input); err != nil { + return "", err + } + return s.publicURL(key), nil +} + +func (s *spaces) Delete(ctx context.Context, key string) error { + key = strings.TrimPrefix(key, "/") + if key == "" { + return nil + } + _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(s.cfg.Bucket), + Key: aws.String(key), + }) + return err +} + +func (s *spaces) publicURL(key string) string { + if s.cfg.CDNBase != "" { + return s.cfg.CDNBase + "/" + key + } + host := strings.TrimPrefix(s.cfg.Endpoint, "https://") + host = strings.TrimPrefix(host, "http://") + return fmt.Sprintf("https://%s.%s/%s", s.cfg.Bucket, host, key) +} + +// KeyFromPublicURL extracts the object key from a Spaces/CDN URL when possible. +func KeyFromPublicURL(publicURL, cdnBase, bucket, endpoint string) string { + publicURL = strings.TrimSpace(publicURL) + if publicURL == "" { + return "" + } + cdnBase = strings.TrimRight(strings.TrimSpace(cdnBase), "/") + if cdnBase != "" && strings.HasPrefix(publicURL, cdnBase+"/") { + return strings.TrimPrefix(publicURL, cdnBase+"/") + } + host := strings.TrimPrefix(strings.TrimSpace(endpoint), "https://") + host = strings.TrimPrefix(host, "http://") + prefix := fmt.Sprintf("https://%s.%s/", bucket, host) + if strings.HasPrefix(publicURL, prefix) { + return strings.TrimPrefix(publicURL, prefix) + } + return "" +} diff --git a/internal/geo/us.go b/internal/geo/us.go new file mode 100644 index 0000000..ff77702 --- /dev/null +++ b/internal/geo/us.go @@ -0,0 +1,56 @@ +package geo + +import "strings" + +// States is the US states + DC allowlist (code -> name). +var States = []struct { + Code string + Name string +}{ + {"AL", "Alabama"}, {"AK", "Alaska"}, {"AZ", "Arizona"}, {"AR", "Arkansas"}, {"CA", "California"}, + {"CO", "Colorado"}, {"CT", "Connecticut"}, {"DE", "Delaware"}, {"DC", "District of Columbia"}, + {"FL", "Florida"}, {"GA", "Georgia"}, {"HI", "Hawaii"}, {"ID", "Idaho"}, {"IL", "Illinois"}, + {"IN", "Indiana"}, {"IA", "Iowa"}, {"KS", "Kansas"}, {"KY", "Kentucky"}, {"LA", "Louisiana"}, + {"ME", "Maine"}, {"MD", "Maryland"}, {"MA", "Massachusetts"}, {"MI", "Michigan"}, {"MN", "Minnesota"}, + {"MS", "Mississippi"}, {"MO", "Missouri"}, {"MT", "Montana"}, {"NE", "Nebraska"}, {"NV", "Nevada"}, + {"NH", "New Hampshire"}, {"NJ", "New Jersey"}, {"NM", "New Mexico"}, {"NY", "New York"}, + {"NC", "North Carolina"}, {"ND", "North Dakota"}, {"OH", "Ohio"}, {"OK", "Oklahoma"}, {"OR", "Oregon"}, + {"PA", "Pennsylvania"}, {"RI", "Rhode Island"}, {"SC", "South Carolina"}, {"SD", "South Dakota"}, + {"TN", "Tennessee"}, {"TX", "Texas"}, {"UT", "Utah"}, {"VT", "Vermont"}, {"VA", "Virginia"}, + {"WA", "Washington"}, {"WV", "West Virginia"}, {"WI", "Wisconsin"}, {"WY", "Wyoming"}, +} + +var codes map[string]struct{} + +func init() { + codes = make(map[string]struct{}, len(States)) + for _, s := range States { + codes[s.Code] = struct{}{} + } +} + +// ValidState reports whether state is empty or a known US code. +func ValidState(state string) bool { + state = strings.ToUpper(strings.TrimSpace(state)) + if state == "" { + return true + } + _, ok := codes[state] + return ok +} + +// NormalizeState returns "" or an uppercase 2-letter code. +func NormalizeState(state string) string { + return strings.ToUpper(strings.TrimSpace(state)) +} + +// StateName returns the full name for a US state code, or "" if unknown. +func StateName(code string) string { + code = NormalizeState(code) + for _, s := range States { + if s.Code == code { + return s.Name + } + } + return "" +} diff --git a/internal/pacific/pacific.go b/internal/pacific/pacific.go new file mode 100644 index 0000000..ab7eae1 --- /dev/null +++ b/internal/pacific/pacific.go @@ -0,0 +1,53 @@ +package pacific + +import ( + "time" + + _ "time/tzdata" +) + +const Layout = "2006-01-02" + +var Loc *time.Location + +func init() { + loc, err := time.LoadLocation("America/Los_Angeles") + if err != nil { + panic(err) + } + Loc = loc +} + +func HuntDate(t time.Time) string { + return t.In(Loc).Format(Layout) +} + +func Today() string { + return HuntDate(time.Now()) +} + +func Yesterday() string { + now := time.Now().In(Loc) + y := time.Date(now.Year(), now.Month(), now.Day()-1, 0, 0, 0, 0, Loc) + return y.Format(Layout) +} + +func Parse(date string) (time.Time, error) { + return time.ParseInLocation(Layout, date, Loc) +} + +func Label(date string) string { + t, err := Parse(date) + if err != nil { + return date + } + return t.Format("January 2, 2006") +} + +func IsToday(date string) bool { + return date == Today() +} + +func IsYesterday(date string) bool { + return date == Yesterday() +} diff --git a/internal/store/answer.go b/internal/store/answer.go new file mode 100644 index 0000000..9dcfe3f --- /dev/null +++ b/internal/store/answer.go @@ -0,0 +1,63 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "plumber/internal/store/sqlc" +) + +// Answer is an admin reply to a question. +type Answer struct { + QuestionID string + AuthorID string + AuthorName string + Body string + CreatedAt string + UpdatedAt string + db *sql.DB +} + +// NewAnswer returns an Answer bound to db. +func NewAnswer(db *sql.DB) *Answer { + return &Answer{db: db} +} + +// Upsert inserts or updates the answer for QuestionID. +func (a *Answer) Upsert(ctx context.Context) error { + if a == nil || a.db == nil { + return fmt.Errorf("answer: no database") + } + a.Body = strings.TrimSpace(a.Body) + now := time.Now().UTC().Format(time.RFC3339) + if a.CreatedAt == "" { + a.CreatedAt = now + } + a.UpdatedAt = now + return sqlc.New(a.db).UpsertAnswer(ctx, sqlc.UpsertAnswerParams{ + QuestionID: a.QuestionID, + AuthorID: a.AuthorID, + Body: a.Body, + CreatedAt: a.CreatedAt, + UpdatedAt: a.UpdatedAt, + }) +} + +func GetAnswer(ctx context.Context, db *sql.DB, questionID string) (*Answer, error) { + r, err := sqlc.New(db).GetAnswer(ctx, questionID) + if err != nil { + return nil, err + } + return &Answer{ + QuestionID: r.QuestionID, + AuthorID: r.AuthorID, + AuthorName: r.AuthorName, + Body: r.Body, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + db: db, + }, nil +} diff --git a/internal/store/generate.go b/internal/store/generate.go new file mode 100644 index 0000000..44730d8 --- /dev/null +++ b/internal/store/generate.go @@ -0,0 +1,3 @@ +package store + +//go:generate make -C ../.. sqlc diff --git a/internal/store/memory.go b/internal/store/memory.go new file mode 100644 index 0000000..93e6280 --- /dev/null +++ b/internal/store/memory.go @@ -0,0 +1,376 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/google/uuid" + + "plumber/internal/pacific" +) + +// Memory is an in-process Store for tests. +type Memory struct { + mu sync.Mutex + users map[string]*User // id -> user + byName map[string]string // username -> id + questions map[string]*RankedQuestion // id -> question + answers map[string]*Answer // questionID -> answer + votes map[string]map[string]int // questionID -> userID -> value +} + +// NewMemory returns an empty Memory store. +func NewMemory() *Memory { + return &Memory{ + users: map[string]*User{}, + byName: map[string]string{}, + questions: map[string]*RankedQuestion{}, + answers: map[string]*Answer{}, + votes: map[string]map[string]int{}, + } +} + +func (m *Memory) CreateUser(_ context.Context, u *User) error { + m.mu.Lock() + defer m.mu.Unlock() + if u.Role != RoleUser && u.Role != RoleAdmin { + return fmt.Errorf("invalid role") + } + u.Username = NormalizeUsername(u.Username) + if _, ok := m.byName[u.Username]; ok { + return ErrDuplicateUsername + } + if u.ID == "" { + u.ID = uuid.NewString() + } + if u.Name == "" { + u.Name = u.Username + } + if u.CreatedAt == "" { + u.CreatedAt = time.Now().UTC().Format(time.RFC3339) + } + role := u.Role + if role == RoleAdmin { + for _, existing := range m.users { + if existing.Role == RoleAdmin { + role = RoleUser + break + } + } + } + cp := *u + cp.Role = role + cp.db = nil + m.users[cp.ID] = &cp + m.byName[cp.Username] = cp.ID + *u = cp + return nil +} + +func (m *Memory) UserByID(_ context.Context, id string) (*User, error) { + m.mu.Lock() + defer m.mu.Unlock() + u, ok := m.users[id] + if !ok { + return nil, sql.ErrNoRows + } + cp := *u + return &cp, nil +} + +func (m *Memory) UserByUsername(_ context.Context, username string) (*User, error) { + m.mu.Lock() + defer m.mu.Unlock() + id, ok := m.byName[NormalizeUsername(username)] + if !ok { + return nil, sql.ErrNoRows + } + cp := *m.users[id] + return &cp, nil +} + +func (m *Memory) ListUsers(_ context.Context, q ListUsersQuery) ([]User, string, string, error) { + m.mu.Lock() + defer m.mu.Unlock() + limit := q.Limit + if limit <= 0 { + limit = AdminUsersLimit + } + search := strings.ToLower(strings.TrimSpace(q.Search)) + out := make([]User, 0, len(m.users)) + for _, u := range m.users { + if search != "" && + !strings.Contains(strings.ToLower(u.Username), search) && + !strings.Contains(strings.ToLower(u.Name), search) { + continue + } + if q.CursorCreated != "" { + if u.CreatedAt > q.CursorCreated { + continue + } + if u.CreatedAt == q.CursorCreated && u.ID >= q.CursorID { + continue + } + } + out = append(out, *u) + } + sort.Slice(out, func(i, j int) bool { + if out[i].CreatedAt != out[j].CreatedAt { + return out[i].CreatedAt > out[j].CreatedAt + } + return out[i].ID > out[j].ID + }) + var nextCreated, nextID string + if len(out) > limit { + last := out[limit-1] + nextCreated, nextID = last.CreatedAt, last.ID + out = out[:limit] + } + return out, nextCreated, nextID, nil +} + +func (m *Memory) CountAdmins(_ context.Context) (int, error) { + m.mu.Lock() + defer m.mu.Unlock() + n := 0 + for _, u := range m.users { + if u.Role == RoleAdmin { + n++ + } + } + return n, nil +} + +// SetUserRole serializes demotions under m.mu (same critical section as count). +func (m *Memory) SetUserRole(_ context.Context, id string, role Role) error { + m.mu.Lock() + defer m.mu.Unlock() + if role != RoleUser && role != RoleAdmin { + return fmt.Errorf("invalid role") + } + u, ok := m.users[id] + if !ok { + return sql.ErrNoRows + } + if u.Role == RoleAdmin && role == RoleUser { + n := 0 + for _, x := range m.users { + if x.Role == RoleAdmin { + n++ + } + } + if n <= 1 { + return ErrLastAdmin + } + } + u.Role = role + return nil +} + +func (m *Memory) SaveUserProfile(_ context.Context, u *User) error { + m.mu.Lock() + defer m.mu.Unlock() + cur, ok := m.users[u.ID] + if !ok { + return sql.ErrNoRows + } + cur.State = strings.TrimSpace(u.State) + if u.AvatarURL != "" { + cur.AvatarURL = u.AvatarURL + } + u.State = cur.State + u.AvatarURL = cur.AvatarURL + return nil +} + +func (m *Memory) CreateQuestion(_ context.Context, q *RankedQuestion) error { + m.mu.Lock() + defer m.mu.Unlock() + q.Title = strings.TrimSpace(q.Title) + q.Body = strings.TrimSpace(q.Body) + q.City = strings.TrimSpace(q.City) + if q.ID == "" { + q.ID = uuid.NewString() + } + if q.HuntDate == "" { + q.HuntDate = pacific.Today() + } + if q.CreatedAt == "" { + q.CreatedAt = time.Now().UTC().Format(time.RFC3339) + } + author, ok := m.users[q.AuthorID] + if !ok { + return fmt.Errorf("unknown author") + } + cp := *q + cp.AuthorName = author.Name + cp.db = nil + m.questions[cp.ID] = &cp + *q = cp + return nil +} + +func (m *Memory) annotate(q *RankedQuestion, viewerID string) RankedQuestion { + out := *q + score := 0 + userVote := 0 + if votes, ok := m.votes[q.ID]; ok { + for uid, v := range votes { + score += v + if uid == viewerID { + userVote = v + } + } + } + _, answered := m.answers[q.ID] + out.Score = score + out.Answered = answered + out.UserVote = userVote + out.db = nil + return out +} + +func (m *Memory) GetQuestion(_ context.Context, id, viewerID string) (*RankedQuestion, error) { + m.mu.Lock() + defer m.mu.Unlock() + q, ok := m.questions[id] + if !ok { + return nil, sql.ErrNoRows + } + out := m.annotate(q, viewerID) + return &out, nil +} + +func (m *Memory) ListHunt(_ context.Context, huntDate, viewerID string) ([]RankedQuestion, error) { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]RankedQuestion, 0) + for _, q := range m.questions { + if q.HuntDate != huntDate || q.Hidden { + continue + } + out = append(out, m.annotate(q, viewerID)) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Score != out[j].Score { + return out[i].Score > out[j].Score + } + return out[i].CreatedAt < out[j].CreatedAt + }) + if len(out) > HuntListLimit { + out = out[:HuntListLimit] + } + return out, nil +} + +func (m *Memory) ListQuestionsByAuthor(_ context.Context, authorID string) ([]RankedQuestion, error) { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]RankedQuestion, 0) + for _, q := range m.questions { + if q.AuthorID != authorID || q.Hidden { + continue + } + out = append(out, m.annotate(q, "")) + } + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt }) + if len(out) > ProfileListLimit { + out = out[:ProfileListLimit] + } + return out, nil +} + +func (m *Memory) ListQuestionsAnsweredBy(_ context.Context, adminID string) ([]RankedQuestion, error) { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]RankedQuestion, 0) + for qid, a := range m.answers { + if a.AuthorID != adminID { + continue + } + q, ok := m.questions[qid] + if !ok || q.Hidden { + continue + } + rq := m.annotate(q, "") + rq.Answered = true + out = append(out, rq) + } + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt }) + if len(out) > ProfileListLimit { + out = out[:ProfileListLimit] + } + return out, nil +} + +func (m *Memory) HideQuestion(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + q, ok := m.questions[id] + if !ok { + return sql.ErrNoRows + } + q.Hidden = true + return nil +} + +func (m *Memory) GetAnswer(_ context.Context, questionID string) (*Answer, error) { + m.mu.Lock() + defer m.mu.Unlock() + a, ok := m.answers[questionID] + if !ok { + return nil, sql.ErrNoRows + } + cp := *a + if u, ok := m.users[a.AuthorID]; ok { + cp.AuthorName = u.Name + } + return &cp, nil +} + +func (m *Memory) UpsertAnswer(_ context.Context, a *Answer) error { + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.questions[a.QuestionID]; !ok { + return fmt.Errorf("unknown question") + } + a.Body = strings.TrimSpace(a.Body) + now := time.Now().UTC().Format(time.RFC3339) + if existing, ok := m.answers[a.QuestionID]; ok { + a.CreatedAt = existing.CreatedAt + } else if a.CreatedAt == "" { + a.CreatedAt = now + } + a.UpdatedAt = now + cp := *a + cp.db = nil + m.answers[a.QuestionID] = &cp + *a = cp + return nil +} + +func (m *Memory) Vote(_ context.Context, userID, questionID string, value int) error { + m.mu.Lock() + defer m.mu.Unlock() + if value != 1 && value != -1 && value != 0 { + return fmt.Errorf("invalid vote") + } + q, ok := m.questions[questionID] + if !ok || q.Hidden { + return ErrHiddenOrMissing + } + if m.votes[questionID] == nil { + m.votes[questionID] = map[string]int{} + } + if value == 0 { + delete(m.votes[questionID], userID) + return nil + } + m.votes[questionID][userID] = value + return nil +} diff --git a/internal/store/memory_test.go b/internal/store/memory_test.go new file mode 100644 index 0000000..6c63541 --- /dev/null +++ b/internal/store/memory_test.go @@ -0,0 +1,101 @@ +package store + +import ( + "context" + "sync" + "testing" +) + +func TestMemoryConcurrentLastAdminDemotion(t *testing.T) { + m := NewMemory() + ctx := context.Background() + a := &User{Username: "admin_a", PasswordHash: "x", Role: RoleUser} + b := &User{Username: "admin_b", PasswordHash: "x", Role: RoleUser} + if err := m.CreateUser(ctx, a); err != nil { + t.Fatal(err) + } + if err := m.CreateUser(ctx, b); err != nil { + t.Fatal(err) + } + if err := m.SetUserRole(ctx, a.ID, RoleAdmin); err != nil { + t.Fatal(err) + } + if err := m.SetUserRole(ctx, b.ID, RoleAdmin); err != nil { + t.Fatal(err) + } + + var wg sync.WaitGroup + errs := make(chan error, 2) + wg.Add(2) + go func() { + defer wg.Done() + errs <- m.SetUserRole(ctx, a.ID, RoleUser) + }() + go func() { + defer wg.Done() + errs <- m.SetUserRole(ctx, b.ID, RoleUser) + }() + wg.Wait() + close(errs) + + var ok, lastAdmin int + for err := range errs { + switch err { + case nil: + ok++ + case ErrLastAdmin: + lastAdmin++ + default: + t.Fatalf("unexpected error: %v", err) + } + } + if ok != 1 || lastAdmin != 1 { + t.Fatalf("want 1 success and 1 ErrLastAdmin, got ok=%d lastAdmin=%d", ok, lastAdmin) + } + n, err := m.CountAdmins(ctx) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("admins remaining = %d, want 1", n) + } +} + +func TestMemoryConcurrentBootstrapAdmin(t *testing.T) { + m := NewMemory() + ctx := context.Background() + a := &User{Username: "boot_a", PasswordHash: "x", Role: RoleAdmin} + b := &User{Username: "boot_b", PasswordHash: "x", Role: RoleAdmin} + + var wg sync.WaitGroup + errs := make(chan error, 2) + wg.Add(2) + go func() { + defer wg.Done() + errs <- m.CreateUser(ctx, a) + }() + go func() { + defer wg.Done() + errs <- m.CreateUser(ctx, b) + }() + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + n, err := m.CountAdmins(ctx) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("bootstrap race left %d admins, want 1", n) + } + if a.Role == RoleAdmin && b.Role == RoleAdmin { + t.Fatal("both users kept RoleAdmin") + } + if a.Role != RoleAdmin && b.Role != RoleAdmin { + t.Fatal("neither user is admin") + } +} diff --git a/internal/store/migrate.go b/internal/store/migrate.go new file mode 100644 index 0000000..abbe1b4 --- /dev/null +++ b/internal/store/migrate.go @@ -0,0 +1,100 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "log" +) + +const migrateLockKey int64 = 0x706c756d5f6d6967 // "plum_mig" + +// migrateUserProfileColumns adds avatar_url and state when missing (existing DBs). +func migrateUserProfileColumns(ctx context.Context, exec execContext) error { + cols := []string{"avatar_url", "state"} + for _, col := range cols { + stmt := fmt.Sprintf(`ALTER TABLE users ADD COLUMN IF NOT EXISTS %s TEXT NOT NULL DEFAULT ''`, col) + if _, err := exec.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("add column %s: %w", col, err) + } + } + return nil +} + +type execContext interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) +} + +// applyMigrations runs versioned migrations under a session-level advisory lock +// held for the entire process (check versions → apply → record). +func applyMigrations(db *sql.DB, schemaSQL string) error { + ctx := context.Background() + conn, err := db.Conn(ctx) + if err != nil { + return err + } + defer conn.Close() + + if _, err := conn.ExecContext(ctx, `SELECT pg_advisory_lock($1)`, migrateLockKey); err != nil { + return fmt.Errorf("migrate lock: %w", err) + } + defer func() { + if _, unlockErr := conn.ExecContext(ctx, `SELECT pg_advisory_unlock($1)`, migrateLockKey); unlockErr != nil { + log.Printf("migrate unlock: %v", unlockErr) + } + }() + + if _, err := conn.ExecContext(ctx, ` +CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() +)`); err != nil { + return fmt.Errorf("schema_migrations: %w", err) + } + + applied, err := appliedVersions(ctx, conn) + if err != nil { + return err + } + + migrations := []struct { + version string + run func(context.Context, execContext) error + }{ + {"001_schema", func(ctx context.Context, exec execContext) error { + return applySchema(ctx, exec, schemaSQL) + }}, + {"002_user_profile_columns", migrateUserProfileColumns}, + } + for _, m := range migrations { + if applied[m.version] { + continue + } + log.Printf("migrate: applying %s", m.version) + if err := m.run(ctx, conn); err != nil { + return fmt.Errorf("migrate %s: %w", m.version, err) + } + if _, err := conn.ExecContext(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, m.version); err != nil { + return fmt.Errorf("record %s: %w", m.version, err) + } + } + return nil +} + +func appliedVersions(ctx context.Context, exec execContext) (map[string]bool, error) { + rows, err := exec.QueryContext(ctx, `SELECT version FROM schema_migrations`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]bool{} + for rows.Next() { + var v string + if err := rows.Scan(&v); err != nil { + return nil, err + } + out[v] = true + } + return out, rows.Err() +} diff --git a/internal/store/postgres.go b/internal/store/postgres.go new file mode 100644 index 0000000..d104baf --- /dev/null +++ b/internal/store/postgres.go @@ -0,0 +1,73 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "net/url" + "strings" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +// applySchema runs semicolon-separated DDL statements. +func applySchema(ctx context.Context, exec execContext, schema string) error { + for _, stmt := range strings.Split(schema, ";") { + stmt = strings.TrimSpace(stmt) + if stmt == "" { + continue + } + if _, err := exec.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("%w: %s", err, stmt) + } + } + return nil +} + +// postgresDSN normalizes DATABASE_URL for pgx (sslmode default, strip unsupported params). +func postgresDSN(raw string) (string, error) { + u, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("DATABASE_URL: %w", err) + } + switch u.Scheme { + case "postgres", "postgresql": + default: + return "", fmt.Errorf("DATABASE_URL must be a postgres URL") + } + q := u.Query() + if strings.EqualFold(q.Get("sslrootcert"), "system") { + q.Del("sslrootcert") + } + q.Del("sslnegotiation") + if q.Get("sslmode") == "" { + q.Set("sslmode", "verify-full") + } + u.RawQuery = q.Encode() + return u.String(), nil +} + +// OpenPostgres connects to Postgres, applies schema/migrations, and starts session cleanup. +func OpenPostgres(databaseURL, schema string) (*sql.DB, *SessionStore, error) { + dsn, err := postgresDSN(databaseURL) + if err != nil { + return nil, nil, err + } + db, err := sql.Open("pgx", dsn) + if err != nil { + return nil, nil, err + } + db.SetMaxOpenConns(20) + db.SetMaxIdleConns(5) + if err := db.Ping(); err != nil { + _ = db.Close() + return nil, nil, fmt.Errorf("postgres ping: %w", err) + } + if err := applyMigrations(db, schema); err != nil { + _ = db.Close() + return nil, nil, fmt.Errorf("migrate: %w", err) + } + sessions := NewSessionStore(db, 5*time.Minute) + return db, sessions, nil +} diff --git a/internal/store/postgres_store.go b/internal/store/postgres_store.go new file mode 100644 index 0000000..f0f92a4 --- /dev/null +++ b/internal/store/postgres_store.go @@ -0,0 +1,147 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/google/uuid" + + "plumber/internal/store/sqlc" +) + +// Postgres implements Store against a sqlc-backed database. +type Postgres struct { + db *sql.DB +} + +// NewPostgres wraps db as a Store. +func NewPostgres(db *sql.DB) *Postgres { + return &Postgres{db: db} +} + +func (p *Postgres) CreateUser(ctx context.Context, u *User) error { + if u == nil { + return fmt.Errorf("user: nil") + } + if u.Role != RoleUser && u.Role != RoleAdmin { + return fmt.Errorf("invalid role") + } + u.Username = NormalizeUsername(u.Username) + if u.ID == "" { + u.ID = uuid.NewString() + } + if u.Name == "" { + u.Name = u.Username + } + if u.CreatedAt == "" { + u.CreatedAt = time.Now().UTC().Format(time.RFC3339) + } + + if u.Role != RoleAdmin { + u.db = p.db + return u.Create(ctx) + } + + // Bootstrap admin: serialize count+insert so two setup-secret registers + // cannot both observe zero admins. + tx, err := p.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, adminRoleLockKey); err != nil { + return err + } + q := sqlc.New(tx) + n, err := q.CountAdmins(ctx, string(RoleAdmin)) + if err != nil { + return err + } + role := RoleAdmin + if n > 0 { + role = RoleUser + } + if err := q.CreateUser(ctx, sqlc.CreateUserParams{ + ID: u.ID, + Username: u.Username, + Name: u.Name, + PasswordHash: u.PasswordHash, + Role: string(role), + CreatedAt: u.CreatedAt, + }); err != nil { + return mapUniqueViolation(err) + } + if err := tx.Commit(); err != nil { + return err + } + u.Role = role + u.db = p.db + return nil +} + +func (p *Postgres) UserByID(ctx context.Context, id string) (*User, error) { + return UserByID(ctx, p.db, id) +} + +func (p *Postgres) UserByUsername(ctx context.Context, username string) (*User, error) { + return UserByUsername(ctx, p.db, username) +} + +func (p *Postgres) ListUsers(ctx context.Context, q ListUsersQuery) ([]User, string, string, error) { + return ListUsers(ctx, p.db, q) +} + +func (p *Postgres) CountAdmins(ctx context.Context) (int, error) { + return CountAdmins(ctx, p.db) +} + +func (p *Postgres) SetUserRole(ctx context.Context, id string, role Role) error { + u := &User{ID: id, db: p.db} + return u.SetRole(ctx, role) +} + +func (p *Postgres) SaveUserProfile(ctx context.Context, u *User) error { + u.db = p.db + return u.SaveProfile(ctx) +} + +func (p *Postgres) CreateQuestion(ctx context.Context, q *RankedQuestion) error { + q.db = p.db + return q.Create(ctx) +} + +func (p *Postgres) GetQuestion(ctx context.Context, id, viewerID string) (*RankedQuestion, error) { + return GetQuestion(ctx, p.db, id, viewerID) +} + +func (p *Postgres) ListHunt(ctx context.Context, huntDate, viewerID string) ([]RankedQuestion, error) { + return ListHunt(ctx, p.db, huntDate, viewerID) +} + +func (p *Postgres) ListQuestionsByAuthor(ctx context.Context, authorID string) ([]RankedQuestion, error) { + return ListQuestionsByAuthor(ctx, p.db, authorID) +} + +func (p *Postgres) ListQuestionsAnsweredBy(ctx context.Context, adminID string) ([]RankedQuestion, error) { + return ListQuestionsAnsweredBy(ctx, p.db, adminID) +} + +func (p *Postgres) HideQuestion(ctx context.Context, id string) error { + q := &RankedQuestion{ID: id, db: p.db} + return q.Hide(ctx) +} + +func (p *Postgres) GetAnswer(ctx context.Context, questionID string) (*Answer, error) { + return GetAnswer(ctx, p.db, questionID) +} + +func (p *Postgres) UpsertAnswer(ctx context.Context, a *Answer) error { + a.db = p.db + return a.Upsert(ctx) +} + +func (p *Postgres) Vote(ctx context.Context, userID, questionID string, value int) error { + return Vote(ctx, p.db, userID, questionID, value) +} diff --git a/internal/store/postgres_test.go b/internal/store/postgres_test.go new file mode 100644 index 0000000..6a6a2bd --- /dev/null +++ b/internal/store/postgres_test.go @@ -0,0 +1,23 @@ +package store + +import ( + "strings" + "testing" +) + +func TestNormalizeUsername(t *testing.T) { + if got := NormalizeUsername(" Alice_1 "); got != "alice_1" { + t.Fatalf("got %q", got) + } +} + +func TestPostgresDSNDefaultsSSLMode(t *testing.T) { + in := "postgresql://user:pass@db.example.com:5432/postgres" + out, err := postgresDSN(in) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "sslmode=verify-full") { + t.Fatalf("missing default sslmode: %s", out) + } +} diff --git a/internal/store/question.go b/internal/store/question.go new file mode 100644 index 0000000..c00d50b --- /dev/null +++ b/internal/store/question.go @@ -0,0 +1,156 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + + "plumber/internal/pacific" + "plumber/internal/store/sqlc" +) + +// RankedQuestion is a question row with score / vote annotations for lists. +type RankedQuestion struct { + ID string + AuthorID string + AuthorName string + Title string + Body string + City string + HuntDate string + Hidden bool + CreatedAt string + Score int + Answered bool + UserVote int + db *sql.DB +} + +// NewQuestion returns a question bound to db (not yet inserted). +func NewQuestion(db *sql.DB) *RankedQuestion { + return &RankedQuestion{db: db} +} + +// Create inserts the question. Sets ID, HuntDate, and CreatedAt when empty. +func (q *RankedQuestion) Create(ctx context.Context) error { + if q == nil || q.db == nil { + return fmt.Errorf("question: no database") + } + q.Title = strings.TrimSpace(q.Title) + q.Body = strings.TrimSpace(q.Body) + q.City = strings.TrimSpace(q.City) + if q.ID == "" { + q.ID = uuid.NewString() + } + if q.HuntDate == "" { + q.HuntDate = pacific.Today() + } + if q.CreatedAt == "" { + q.CreatedAt = time.Now().UTC().Format(time.RFC3339) + } + return sqlc.New(q.db).CreateQuestion(ctx, sqlc.CreateQuestionParams{ + ID: q.ID, + AuthorID: q.AuthorID, + Title: q.Title, + Body: q.Body, + City: q.City, + HuntDate: q.HuntDate, + CreatedAt: q.CreatedAt, + }) +} + +// Hide marks the question hidden. +func (q *RankedQuestion) Hide(ctx context.Context) error { + if q == nil || q.db == nil { + return fmt.Errorf("question: no database") + } + if err := sqlc.New(q.db).HideQuestion(ctx, q.ID); err != nil { + return err + } + q.Hidden = true + return nil +} + +func rankedFrom( + db *sql.DB, + id, authorID, authorName, title, body, city, huntDate, createdAt string, + hidden int32, score, answered, userVote int64, +) RankedQuestion { + return RankedQuestion{ + ID: id, + AuthorID: authorID, + AuthorName: authorName, + Title: title, + Body: body, + City: city, + HuntDate: huntDate, + Hidden: hidden != 0, + CreatedAt: createdAt, + Score: int(score), + Answered: answered != 0, + UserVote: int(userVote), + db: db, + } +} + +func ListHunt(ctx context.Context, db *sql.DB, huntDate, viewerID string) ([]RankedQuestion, error) { + rows, err := sqlc.New(db).ListHunt(ctx, sqlc.ListHuntParams{ + ViewerID: viewerID, + HuntDate: huntDate, + RowLimit: HuntListLimit, + }) + if err != nil { + return nil, err + } + out := make([]RankedQuestion, 0, len(rows)) + for _, r := range rows { + out = append(out, rankedFrom(db, r.ID, r.AuthorID, r.AuthorName, r.Title, r.Body, r.City, r.HuntDate, r.CreatedAt, r.Hidden, r.Score, r.Answered, r.UserVote)) + } + return out, nil +} + +func GetQuestion(ctx context.Context, db *sql.DB, id, viewerID string) (*RankedQuestion, error) { + r, err := sqlc.New(db).GetQuestion(ctx, sqlc.GetQuestionParams{ + ViewerID: viewerID, + ID: id, + }) + if err != nil { + return nil, err + } + q := rankedFrom(db, r.ID, r.AuthorID, r.AuthorName, r.Title, r.Body, r.City, r.HuntDate, r.CreatedAt, r.Hidden, r.Score, r.Answered, r.UserVote) + return &q, nil +} + +func ListQuestionsByAuthor(ctx context.Context, db *sql.DB, authorID string) ([]RankedQuestion, error) { + rows, err := sqlc.New(db).ListQuestionsByAuthor(ctx, sqlc.ListQuestionsByAuthorParams{ + AuthorID: authorID, + RowLimit: ProfileListLimit, + }) + if err != nil { + return nil, err + } + out := make([]RankedQuestion, 0, len(rows)) + for _, r := range rows { + out = append(out, rankedFrom(db, r.ID, r.AuthorID, r.AuthorName, r.Title, r.Body, r.City, r.HuntDate, r.CreatedAt, r.Hidden, r.Score, r.Answered, r.UserVote)) + } + return out, nil +} + +func ListQuestionsAnsweredBy(ctx context.Context, db *sql.DB, adminID string) ([]RankedQuestion, error) { + rows, err := sqlc.New(db).ListQuestionsAnsweredBy(ctx, sqlc.ListQuestionsAnsweredByParams{ + AdminID: adminID, + RowLimit: ProfileListLimit, + }) + if err != nil { + return nil, err + } + out := make([]RankedQuestion, 0, len(rows)) + for _, r := range rows { + out = append(out, rankedFrom(db, r.ID, r.AuthorID, r.AuthorName, r.Title, r.Body, r.City, r.HuntDate, r.CreatedAt, r.Hidden, r.Score, r.Answered, r.UserVote)) + } + return out, nil +} diff --git a/internal/store/sessions.go b/internal/store/sessions.go new file mode 100644 index 0000000..a5d4a38 --- /dev/null +++ b/internal/store/sessions.go @@ -0,0 +1,126 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "log" + "sync" + "time" + + "github.com/alexedwards/scs/v2" + + "plumber/internal/store/sqlc" +) + +// SessionStore persists scs sessions in Postgres via sqlc and optionally +// deletes expired rows on an interval. +type SessionStore struct { + db *sql.DB + q *sqlc.Queries + stop chan struct{} + stopped chan struct{} + once sync.Once +} + +// NewSessionStore creates a store backed by db. cleanupInterval > 0 starts a +// background goroutine that deletes expired sessions; 0 disables cleanup. +func NewSessionStore(db *sql.DB, cleanupInterval time.Duration) *SessionStore { + s := &SessionStore{ + db: db, + q: sqlc.New(db), + } + if cleanupInterval > 0 { + s.stop = make(chan struct{}) + s.stopped = make(chan struct{}) + go s.cleanupLoop(cleanupInterval) + } + return s +} + +// Store returns the scs.Store implementation (s itself). +func (s *SessionStore) Store() scs.Store { + return s +} + +// Find implements scs.Store. +func (s *SessionStore) Find(token string) ([]byte, bool, error) { + return s.FindCtx(context.Background(), token) +} + +// Commit implements scs.Store. +func (s *SessionStore) Commit(token string, data []byte, expiry time.Time) error { + return s.CommitCtx(context.Background(), token, data, expiry) +} + +// Delete implements scs.Store. +func (s *SessionStore) Delete(token string) error { + return s.DeleteCtx(context.Background(), token) +} + +// FindCtx implements scs.CtxStore. +func (s *SessionStore) FindCtx(ctx context.Context, token string) ([]byte, bool, error) { + data, err := s.q.GetSession(ctx, token) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, false, nil + } + return nil, false, err + } + return data, true, nil +} + +// CommitCtx implements scs.CtxStore. +func (s *SessionStore) CommitCtx(ctx context.Context, token string, data []byte, expiry time.Time) error { + return s.q.UpsertSession(ctx, sqlc.UpsertSessionParams{ + Token: token, + Data: data, + Expiry: expiry, + }) +} + +// DeleteCtx implements scs.CtxStore. +func (s *SessionStore) DeleteCtx(ctx context.Context, token string) error { + return s.q.DeleteSession(ctx, token) +} + +// StopCleanup stops the background expiry deleter. Safe to call multiple times. +func (s *SessionStore) StopCleanup() { + if s == nil || s.stop == nil { + return + } + s.once.Do(func() { + close(s.stop) + <-s.stopped + }) +} + +// Close stops background session cleanup. +func (s *SessionStore) Close() { + s.StopCleanup() +} + +func (s *SessionStore) cleanupLoop(interval time.Duration) { + defer close(s.stopped) + ticker := time.NewTicker(interval) + defer ticker.Stop() + var lastErrLog time.Time + for { + select { + case <-ticker.C: + n, err := s.q.DeleteExpiredSessions(context.Background()) + if err != nil { + if time.Since(lastErrLog) > time.Minute { + log.Printf("session cleanup: %v", err) + lastErrLog = time.Now() + } + continue + } + if n > 0 { + log.Printf("session cleanup: deleted %d expired row(s)", n) + } + case <-s.stop: + return + } + } +} diff --git a/internal/store/sessions_test.go b/internal/store/sessions_test.go new file mode 100644 index 0000000..d2caf4f --- /dev/null +++ b/internal/store/sessions_test.go @@ -0,0 +1,52 @@ +package store + +import ( + "os" + "testing" + "time" +) + +func TestSessionStoreCommitFindDelete(t *testing.T) { + url := os.Getenv("TEST_DATABASE_URL") + if url == "" { + t.Skip("TEST_DATABASE_URL not set") + } + schema, err := os.ReadFile("../../schema.sql") + if err != nil { + t.Fatal(err) + } + db, sessions, err := OpenPostgres(url, string(schema)) + if err != nil { + t.Fatal(err) + } + defer db.Close() + defer sessions.Close() + + token := "test-session-" + time.Now().Format("20060102150405.000000000") + data := []byte("hello-session") + expiry := time.Now().Add(time.Hour) + + if err := sessions.Commit(token, data, expiry); err != nil { + t.Fatalf("Commit: %v", err) + } + got, found, err := sessions.Find(token) + if err != nil { + t.Fatalf("Find: %v", err) + } + if !found { + t.Fatal("expected found") + } + if string(got) != string(data) { + t.Fatalf("data = %q, want %q", got, data) + } + if err := sessions.Delete(token); err != nil { + t.Fatalf("Delete: %v", err) + } + _, found, err = sessions.Find(token) + if err != nil { + t.Fatalf("Find after delete: %v", err) + } + if found { + t.Fatal("expected not found after delete") + } +} diff --git a/internal/store/sqlc/answers.sql.go b/internal/store/sqlc/answers.sql.go new file mode 100644 index 0000000..2cdf6dc --- /dev/null +++ b/internal/store/sqlc/answers.sql.go @@ -0,0 +1,66 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: answers.sql + +package sqlc + +import ( + "context" +) + +const getAnswer = `-- name: GetAnswer :one +SELECT a.question_id, a.author_id, u.name AS author_name, a.body, a.created_at, a.updated_at +FROM answers a +JOIN users u ON u.id = a.author_id +WHERE a.question_id = $1 +` + +type GetAnswerRow struct { + QuestionID string + AuthorID string + AuthorName string + Body string + CreatedAt string + UpdatedAt string +} + +func (q *Queries) GetAnswer(ctx context.Context, questionID string) (GetAnswerRow, error) { + row := q.db.QueryRowContext(ctx, getAnswer, questionID) + var i GetAnswerRow + err := row.Scan( + &i.QuestionID, + &i.AuthorID, + &i.AuthorName, + &i.Body, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const upsertAnswer = `-- name: UpsertAnswer :exec +INSERT INTO answers (question_id, author_id, body, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (question_id) DO UPDATE +SET body = excluded.body, author_id = excluded.author_id, updated_at = excluded.updated_at +` + +type UpsertAnswerParams struct { + QuestionID string + AuthorID string + Body string + CreatedAt string + UpdatedAt string +} + +func (q *Queries) UpsertAnswer(ctx context.Context, arg UpsertAnswerParams) error { + _, err := q.db.ExecContext(ctx, upsertAnswer, + arg.QuestionID, + arg.AuthorID, + arg.Body, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} diff --git a/internal/store/sqlc/db.go b/internal/store/sqlc/db.go new file mode 100644 index 0000000..5922bc9 --- /dev/null +++ b/internal/store/sqlc/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlc + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/store/sqlc/models.go b/internal/store/sqlc/models.go new file mode 100644 index 0000000..7de647f --- /dev/null +++ b/internal/store/sqlc/models.go @@ -0,0 +1,51 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlc + +import ( + "time" +) + +type Answer struct { + QuestionID string + AuthorID string + Body string + CreatedAt string + UpdatedAt string +} + +type Question struct { + ID string + AuthorID string + Title string + Body string + City string + HuntDate string + Hidden int32 + CreatedAt string +} + +type Session struct { + Token string + Data []byte + Expiry time.Time +} + +type User struct { + ID string + Username string + Name string + PasswordHash string + Role string + AvatarUrl string + State string + CreatedAt string +} + +type Vote struct { + UserID string + QuestionID string + Value int32 +} diff --git a/internal/store/sqlc/questions.sql.go b/internal/store/sqlc/questions.sql.go new file mode 100644 index 0000000..31bcf09 --- /dev/null +++ b/internal/store/sqlc/questions.sql.go @@ -0,0 +1,316 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: questions.sql + +package sqlc + +import ( + "context" +) + +const createQuestion = `-- name: CreateQuestion :exec +INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at) +VALUES ($1, $2, $3, $4, $5, $6, 0, $7) +` + +type CreateQuestionParams struct { + ID string + AuthorID string + Title string + Body string + City string + HuntDate string + CreatedAt string +} + +func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams) error { + _, err := q.db.ExecContext(ctx, createQuestion, + arg.ID, + arg.AuthorID, + arg.Title, + arg.Body, + arg.City, + arg.HuntDate, + arg.CreatedAt, + ) + return err +} + +const getQuestion = `-- name: GetQuestion :one +SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, + COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score, + CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered, + COALESCE(( + SELECT votes.value FROM votes + WHERE votes.user_id = $1 AND votes.question_id = q.id + ), 0)::bigint AS user_vote +FROM questions q +JOIN users u ON u.id = q.author_id +LEFT JOIN answers a ON a.question_id = q.id +WHERE q.id = $2 +` + +type GetQuestionParams struct { + ViewerID string + ID string +} + +type GetQuestionRow struct { + ID string + AuthorID string + AuthorName string + Title string + Body string + City string + HuntDate string + Hidden int32 + CreatedAt string + Score int64 + Answered int64 + UserVote int64 +} + +func (q *Queries) GetQuestion(ctx context.Context, arg GetQuestionParams) (GetQuestionRow, error) { + row := q.db.QueryRowContext(ctx, getQuestion, arg.ViewerID, arg.ID) + var i GetQuestionRow + err := row.Scan( + &i.ID, + &i.AuthorID, + &i.AuthorName, + &i.Title, + &i.Body, + &i.City, + &i.HuntDate, + &i.Hidden, + &i.CreatedAt, + &i.Score, + &i.Answered, + &i.UserVote, + ) + return i, err +} + +const hideQuestion = `-- name: HideQuestion :exec +UPDATE questions +SET hidden = 1 +WHERE id = $1 +` + +func (q *Queries) HideQuestion(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, hideQuestion, id) + return err +} + +const listHunt = `-- name: ListHunt :many +SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, + COALESCE(SUM(v.value), 0)::bigint AS score, + CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered, + COALESCE(( + SELECT votes.value FROM votes + WHERE votes.user_id = $1 AND votes.question_id = q.id + ), 0)::bigint AS user_vote +FROM questions q +JOIN users u ON u.id = q.author_id +LEFT JOIN votes v ON v.question_id = q.id +LEFT JOIN answers a ON a.question_id = q.id +WHERE q.hunt_date = $2 AND q.hidden = 0 +GROUP BY q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, a.question_id +ORDER BY score DESC, q.created_at ASC +LIMIT $3 +` + +type ListHuntParams struct { + ViewerID string + HuntDate string + RowLimit int32 +} + +type ListHuntRow struct { + ID string + AuthorID string + AuthorName string + Title string + Body string + City string + HuntDate string + Hidden int32 + CreatedAt string + Score int64 + Answered int64 + UserVote int64 +} + +func (q *Queries) ListHunt(ctx context.Context, arg ListHuntParams) ([]ListHuntRow, error) { + rows, err := q.db.QueryContext(ctx, listHunt, arg.ViewerID, arg.HuntDate, arg.RowLimit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListHuntRow{} + for rows.Next() { + var i ListHuntRow + if err := rows.Scan( + &i.ID, + &i.AuthorID, + &i.AuthorName, + &i.Title, + &i.Body, + &i.City, + &i.HuntDate, + &i.Hidden, + &i.CreatedAt, + &i.Score, + &i.Answered, + &i.UserVote, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listQuestionsAnsweredBy = `-- name: ListQuestionsAnsweredBy :many +SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, + COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score, + 1::bigint AS answered, + 0::bigint AS user_vote +FROM answers ans +JOIN questions q ON q.id = ans.question_id +JOIN users u ON u.id = q.author_id +WHERE ans.author_id = $1 AND q.hidden = 0 +ORDER BY ans.updated_at DESC +LIMIT $2 +` + +type ListQuestionsAnsweredByParams struct { + AdminID string + RowLimit int32 +} + +type ListQuestionsAnsweredByRow struct { + ID string + AuthorID string + AuthorName string + Title string + Body string + City string + HuntDate string + Hidden int32 + CreatedAt string + Score int64 + Answered int64 + UserVote int64 +} + +func (q *Queries) ListQuestionsAnsweredBy(ctx context.Context, arg ListQuestionsAnsweredByParams) ([]ListQuestionsAnsweredByRow, error) { + rows, err := q.db.QueryContext(ctx, listQuestionsAnsweredBy, arg.AdminID, arg.RowLimit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListQuestionsAnsweredByRow{} + for rows.Next() { + var i ListQuestionsAnsweredByRow + if err := rows.Scan( + &i.ID, + &i.AuthorID, + &i.AuthorName, + &i.Title, + &i.Body, + &i.City, + &i.HuntDate, + &i.Hidden, + &i.CreatedAt, + &i.Score, + &i.Answered, + &i.UserVote, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listQuestionsByAuthor = `-- name: ListQuestionsByAuthor :many +SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, + COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score, + CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered, + 0::bigint AS user_vote +FROM questions q +JOIN users u ON u.id = q.author_id +LEFT JOIN answers a ON a.question_id = q.id +WHERE q.author_id = $1 AND q.hidden = 0 +ORDER BY q.created_at DESC +LIMIT $2 +` + +type ListQuestionsByAuthorParams struct { + AuthorID string + RowLimit int32 +} + +type ListQuestionsByAuthorRow struct { + ID string + AuthorID string + AuthorName string + Title string + Body string + City string + HuntDate string + Hidden int32 + CreatedAt string + Score int64 + Answered int64 + UserVote int64 +} + +func (q *Queries) ListQuestionsByAuthor(ctx context.Context, arg ListQuestionsByAuthorParams) ([]ListQuestionsByAuthorRow, error) { + rows, err := q.db.QueryContext(ctx, listQuestionsByAuthor, arg.AuthorID, arg.RowLimit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListQuestionsByAuthorRow{} + for rows.Next() { + var i ListQuestionsByAuthorRow + if err := rows.Scan( + &i.ID, + &i.AuthorID, + &i.AuthorName, + &i.Title, + &i.Body, + &i.City, + &i.HuntDate, + &i.Hidden, + &i.CreatedAt, + &i.Score, + &i.Answered, + &i.UserVote, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/store/sqlc/sessions.sql.go b/internal/store/sqlc/sessions.sql.go new file mode 100644 index 0000000..7c9f9ec --- /dev/null +++ b/internal/store/sqlc/sessions.sql.go @@ -0,0 +1,65 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: sessions.sql + +package sqlc + +import ( + "context" + "time" +) + +const deleteExpiredSessions = `-- name: DeleteExpiredSessions :execrows +DELETE FROM sessions +WHERE expiry <= now() +` + +func (q *Queries) DeleteExpiredSessions(ctx context.Context) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteExpiredSessions) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const deleteSession = `-- name: DeleteSession :exec +DELETE FROM sessions +WHERE token = $1 +` + +func (q *Queries) DeleteSession(ctx context.Context, token string) error { + _, err := q.db.ExecContext(ctx, deleteSession, token) + return err +} + +const getSession = `-- name: GetSession :one +SELECT data +FROM sessions +WHERE token = $1 AND expiry > now() +` + +func (q *Queries) GetSession(ctx context.Context, token string) ([]byte, error) { + row := q.db.QueryRowContext(ctx, getSession, token) + var data []byte + err := row.Scan(&data) + return data, err +} + +const upsertSession = `-- name: UpsertSession :exec +INSERT INTO sessions (token, data, expiry) +VALUES ($1, $2, $3) +ON CONFLICT (token) DO UPDATE +SET data = excluded.data, expiry = excluded.expiry +` + +type UpsertSessionParams struct { + Token string + Data []byte + Expiry time.Time +} + +func (q *Queries) UpsertSession(ctx context.Context, arg UpsertSessionParams) error { + _, err := q.db.ExecContext(ctx, upsertSession, arg.Token, arg.Data, arg.Expiry) + return err +} diff --git a/internal/store/sqlc/users.sql.go b/internal/store/sqlc/users.sql.go new file mode 100644 index 0000000..ffa9085 --- /dev/null +++ b/internal/store/sqlc/users.sql.go @@ -0,0 +1,245 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: users.sql + +package sqlc + +import ( + "context" + "database/sql" +) + +const countAdmins = `-- name: CountAdmins :one +SELECT COUNT(*)::bigint AS count +FROM users +WHERE role = $1 +` + +func (q *Queries) CountAdmins(ctx context.Context, role string) (int64, error) { + row := q.db.QueryRowContext(ctx, countAdmins, role) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createUser = `-- name: CreateUser :exec +INSERT INTO users (id, username, name, password_hash, role, avatar_url, state, created_at) +VALUES ($1, $2, $3, $4, $5, '', '', $6) +` + +type CreateUserParams struct { + ID string + Username string + Name string + PasswordHash string + Role string + CreatedAt string +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) error { + _, err := q.db.ExecContext(ctx, createUser, + arg.ID, + arg.Username, + arg.Name, + arg.PasswordHash, + arg.Role, + arg.CreatedAt, + ) + return err +} + +const getUserByID = `-- name: GetUserByID :one +SELECT id, username, name, role, avatar_url, state, created_at +FROM users +WHERE id = $1 +` + +type GetUserByIDRow struct { + ID string + Username string + Name string + Role string + AvatarUrl string + State string + CreatedAt string +} + +func (q *Queries) GetUserByID(ctx context.Context, id string) (GetUserByIDRow, error) { + row := q.db.QueryRowContext(ctx, getUserByID, id) + var i GetUserByIDRow + err := row.Scan( + &i.ID, + &i.Username, + &i.Name, + &i.Role, + &i.AvatarUrl, + &i.State, + &i.CreatedAt, + ) + return i, err +} + +const getUserByUsername = `-- name: GetUserByUsername :one +SELECT id, username, name, role, avatar_url, state, created_at, password_hash +FROM users +WHERE username = $1 +` + +type GetUserByUsernameRow struct { + ID string + Username string + Name string + Role string + AvatarUrl string + State string + CreatedAt string + PasswordHash string +} + +func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error) { + row := q.db.QueryRowContext(ctx, getUserByUsername, username) + var i GetUserByUsernameRow + err := row.Scan( + &i.ID, + &i.Username, + &i.Name, + &i.Role, + &i.AvatarUrl, + &i.State, + &i.CreatedAt, + &i.PasswordHash, + ) + return i, err +} + +const getUserRole = `-- name: GetUserRole :one +SELECT role +FROM users +WHERE id = $1 +` + +func (q *Queries) GetUserRole(ctx context.Context, id string) (string, error) { + row := q.db.QueryRowContext(ctx, getUserRole, id) + var role string + err := row.Scan(&role) + return role, err +} + +const listUsers = `-- name: ListUsers :many +SELECT id, username, name, role, avatar_url, state, created_at +FROM users +WHERE ( + $1 = '' + OR username ILIKE '%' || $1 || '%' + OR name ILIKE '%' || $1 || '%' +) +AND ( + $2 = '' + OR created_at < $2 + OR (created_at = $2 AND id < $3) +) +ORDER BY created_at DESC, id DESC +LIMIT $4 +` + +type ListUsersParams struct { + Search interface{} + CursorCreated interface{} + CursorID string + RowLimit int32 +} + +type ListUsersRow struct { + ID string + Username string + Name string + Role string + AvatarUrl string + State string + CreatedAt string +} + +func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]ListUsersRow, error) { + rows, err := q.db.QueryContext(ctx, listUsers, + arg.Search, + arg.CursorCreated, + arg.CursorID, + arg.RowLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListUsersRow{} + for rows.Next() { + var i ListUsersRow + if err := rows.Scan( + &i.ID, + &i.Username, + &i.Name, + &i.Role, + &i.AvatarUrl, + &i.State, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateUserRole = `-- name: UpdateUserRole :execresult +UPDATE users +SET role = $1 +WHERE id = $2 +` + +type UpdateUserRoleParams struct { + Role string + ID string +} + +func (q *Queries) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (sql.Result, error) { + return q.db.ExecContext(ctx, updateUserRole, arg.Role, arg.ID) +} + +const updateUserState = `-- name: UpdateUserState :exec +UPDATE users +SET state = $1 +WHERE id = $2 +` + +type UpdateUserStateParams struct { + State string + ID string +} + +func (q *Queries) UpdateUserState(ctx context.Context, arg UpdateUserStateParams) error { + _, err := q.db.ExecContext(ctx, updateUserState, arg.State, arg.ID) + return err +} + +const updateUserStateAndAvatar = `-- name: UpdateUserStateAndAvatar :exec +UPDATE users +SET state = $1, avatar_url = $2 +WHERE id = $3 +` + +type UpdateUserStateAndAvatarParams struct { + State string + AvatarUrl string + ID string +} + +func (q *Queries) UpdateUserStateAndAvatar(ctx context.Context, arg UpdateUserStateAndAvatarParams) error { + _, err := q.db.ExecContext(ctx, updateUserStateAndAvatar, arg.State, arg.AvatarUrl, arg.ID) + return err +} diff --git a/internal/store/sqlc/votes.sql.go b/internal/store/sqlc/votes.sql.go new file mode 100644 index 0000000..503901d --- /dev/null +++ b/internal/store/sqlc/votes.sql.go @@ -0,0 +1,82 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: votes.sql + +package sqlc + +import ( + "context" +) + +const deleteVote = `-- name: DeleteVote :exec +DELETE FROM votes +WHERE user_id = $1 AND question_id = $2 +` + +type DeleteVoteParams struct { + UserID string + QuestionID string +} + +func (q *Queries) DeleteVote(ctx context.Context, arg DeleteVoteParams) error { + _, err := q.db.ExecContext(ctx, deleteVote, arg.UserID, arg.QuestionID) + return err +} + +const getVote = `-- name: GetVote :one +SELECT value +FROM votes +WHERE user_id = $1 AND question_id = $2 +` + +type GetVoteParams struct { + UserID string + QuestionID string +} + +func (q *Queries) GetVote(ctx context.Context, arg GetVoteParams) (int32, error) { + row := q.db.QueryRowContext(ctx, getVote, arg.UserID, arg.QuestionID) + var value int32 + err := row.Scan(&value) + return value, err +} + +const questionIsVisible = `-- name: QuestionIsVisible :one +SELECT EXISTS( + SELECT 1 FROM questions WHERE id = $1 AND hidden = 0 +)::bool +` + +func (q *Queries) QuestionIsVisible(ctx context.Context, id string) (bool, error) { + row := q.db.QueryRowContext(ctx, questionIsVisible, id) + var column_1 bool + err := row.Scan(&column_1) + return column_1, err +} + +const upsertVoteOnVisible = `-- name: UpsertVoteOnVisible :execrows +INSERT INTO votes (user_id, question_id, value) +SELECT $1, $2, $3 +FROM questions q +WHERE q.id = $2 AND q.hidden = 0 +ON CONFLICT (user_id, question_id) DO UPDATE +SET value = excluded.value +WHERE EXISTS ( + SELECT 1 FROM questions q2 WHERE q2.id = excluded.question_id AND q2.hidden = 0 +) +` + +type UpsertVoteOnVisibleParams struct { + UserID string + QuestionID string + Value int32 +} + +func (q *Queries) UpsertVoteOnVisible(ctx context.Context, arg UpsertVoteOnVisibleParams) (int64, error) { + result, err := q.db.ExecContext(ctx, upsertVoteOnVisible, arg.UserID, arg.QuestionID, arg.Value) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..2faf5be --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,42 @@ +package store + +import "context" + +// List row caps keep hunt/profile/admin pages bounded. +const ( + HuntListLimit = 100 + ProfileListLimit = 50 + AdminUsersLimit = 50 +) + +// ListUsersQuery is a paginated admin user search. +type ListUsersQuery struct { + Search string + CursorCreated string + CursorID string + Limit int +} + +// Store is the application persistence API used by the web layer. +type Store interface { + CreateUser(ctx context.Context, u *User) error + UserByID(ctx context.Context, id string) (*User, error) + UserByUsername(ctx context.Context, username string) (*User, error) + ListUsers(ctx context.Context, q ListUsersQuery) (users []User, nextCursorCreated, nextCursorID string, err error) + CountAdmins(ctx context.Context) (int, error) + SetUserRole(ctx context.Context, id string, role Role) error + SaveUserProfile(ctx context.Context, u *User) error + + CreateQuestion(ctx context.Context, q *RankedQuestion) error + GetQuestion(ctx context.Context, id, viewerID string) (*RankedQuestion, error) + ListHunt(ctx context.Context, huntDate, viewerID string) ([]RankedQuestion, error) + ListQuestionsByAuthor(ctx context.Context, authorID string) ([]RankedQuestion, error) + ListQuestionsAnsweredBy(ctx context.Context, adminID string) ([]RankedQuestion, error) + HideQuestion(ctx context.Context, id string) error + + GetAnswer(ctx context.Context, questionID string) (*Answer, error) + UpsertAnswer(ctx context.Context, a *Answer) error + + // Vote sets the vote to 1, -1, or 0 (clear) on a visible question. + Vote(ctx context.Context, userID, questionID string, value int) error +} diff --git a/internal/store/user.go b/internal/store/user.go new file mode 100644 index 0000000..299c616 --- /dev/null +++ b/internal/store/user.go @@ -0,0 +1,216 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + + "plumber/internal/store/sqlc" +) + +// ErrLastAdmin is returned when demoting the only remaining admin. +var ErrLastAdmin = errors.New("cannot demote the last admin") + +// Role is a user privilege level stored in users.role. +type Role string + +const ( + RoleUser Role = "user" + RoleAdmin Role = "admin" +) + +// User is an account row. Methods run SQL against db via sqlc. +type User struct { + ID string + Username string + Name string + Role Role + AvatarURL string + State string + CreatedAt string + PasswordHash string + db *sql.DB +} + +// NewUser returns a User bound to db (not yet inserted). +func NewUser(db *sql.DB) *User { + return &User{db: db} +} + +func (u *User) Admin() bool { + return u != nil && u.Role == RoleAdmin +} + +func NormalizeUsername(s string) string { + return strings.ToLower(strings.TrimSpace(s)) +} + +func toUser(db *sql.DB, id, username, name, role, avatarURL, state, createdAt, passwordHash string) *User { + return &User{ + ID: id, + Username: username, + Name: name, + Role: Role(role), + AvatarURL: avatarURL, + State: state, + CreatedAt: createdAt, + PasswordHash: passwordHash, + db: db, + } +} + +// Create inserts the user. Sets ID, Name, and CreatedAt when empty. +func (u *User) Create(ctx context.Context) error { + if u == nil || u.db == nil { + return fmt.Errorf("user: no database") + } + if u.Role != RoleUser && u.Role != RoleAdmin { + return fmt.Errorf("invalid role") + } + u.Username = NormalizeUsername(u.Username) + if u.ID == "" { + u.ID = uuid.NewString() + } + if u.Name == "" { + u.Name = u.Username + } + if u.CreatedAt == "" { + u.CreatedAt = time.Now().UTC().Format(time.RFC3339) + } + return mapUniqueViolation(sqlc.New(u.db).CreateUser(ctx, sqlc.CreateUserParams{ + ID: u.ID, + Username: u.Username, + Name: u.Name, + PasswordHash: u.PasswordHash, + Role: string(u.Role), + CreatedAt: u.CreatedAt, + })) +} + +// adminRoleLockKey serializes SetRole so concurrent demotions cannot bypass the +// last-admin guard under READ COMMITTED. +const adminRoleLockKey int64 = 0x706c756d5f61646d // "plum_adm" + +// SetRole updates this user's role (last-admin safe). +func (u *User) SetRole(ctx context.Context, role Role) error { + if u == nil || u.db == nil { + return fmt.Errorf("user: no database") + } + if role != RoleUser && role != RoleAdmin { + return fmt.Errorf("invalid role") + } + tx, err := u.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, adminRoleLockKey); err != nil { + return err + } + + q := sqlc.New(tx) + current, err := q.GetUserRole(ctx, u.ID) + if err != nil { + return err + } + if Role(current) == RoleAdmin && role == RoleUser { + n, err := q.CountAdmins(ctx, string(RoleAdmin)) + if err != nil { + return err + } + if n <= 1 { + return ErrLastAdmin + } + } + res, err := q.UpdateUserRole(ctx, sqlc.UpdateUserRoleParams{ + Role: string(role), + ID: u.ID, + }) + if err != nil { + return err + } + aff, err := res.RowsAffected() + if err != nil { + return err + } + if aff == 0 { + return sql.ErrNoRows + } + if err := tx.Commit(); err != nil { + return err + } + u.Role = role + return nil +} + +// SaveProfile writes State and optionally AvatarURL. +func (u *User) SaveProfile(ctx context.Context) error { + if u == nil || u.db == nil { + return fmt.Errorf("user: no database") + } + u.State = strings.TrimSpace(u.State) + q := sqlc.New(u.db) + if u.AvatarURL == "" { + return q.UpdateUserState(ctx, sqlc.UpdateUserStateParams{State: u.State, ID: u.ID}) + } + return q.UpdateUserStateAndAvatar(ctx, sqlc.UpdateUserStateAndAvatarParams{ + State: u.State, + AvatarUrl: u.AvatarURL, + ID: u.ID, + }) +} + +func CountAdmins(ctx context.Context, db *sql.DB) (int, error) { + n, err := sqlc.New(db).CountAdmins(ctx, string(RoleAdmin)) + return int(n), err +} + +func ListUsers(ctx context.Context, db *sql.DB, q ListUsersQuery) ([]User, string, string, error) { + limit := q.Limit + if limit <= 0 { + limit = AdminUsersLimit + } + rows, err := sqlc.New(db).ListUsers(ctx, sqlc.ListUsersParams{ + Search: q.Search, + CursorCreated: q.CursorCreated, + CursorID: q.CursorID, + RowLimit: int32(limit + 1), + }) + if err != nil { + return nil, "", "", err + } + out := make([]User, 0, len(rows)) + for _, r := range rows { + u := toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, "") + out = append(out, *u) + } + var nextCreated, nextID string + if len(out) > limit { + last := out[limit-1] + nextCreated, nextID = last.CreatedAt, last.ID + out = out[:limit] + } + return out, nextCreated, nextID, nil +} + +func UserByID(ctx context.Context, db *sql.DB, id string) (*User, error) { + r, err := sqlc.New(db).GetUserByID(ctx, id) + if err != nil { + return nil, err + } + return toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, ""), nil +} + +func UserByUsername(ctx context.Context, db *sql.DB, username string) (*User, error) { + r, err := sqlc.New(db).GetUserByUsername(ctx, NormalizeUsername(username)) + if err != nil { + return nil, err + } + return toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, r.PasswordHash), nil +} diff --git a/internal/store/vote.go b/internal/store/vote.go new file mode 100644 index 0000000..2d24a3c --- /dev/null +++ b/internal/store/vote.go @@ -0,0 +1,64 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/jackc/pgx/v5/pgconn" + + "plumber/internal/store/sqlc" +) + +// ErrDuplicateUsername is returned when inserting a username that already exists. +var ErrDuplicateUsername = errors.New("username taken") + +// ErrHiddenOrMissing is returned when voting on a hidden or unknown question. +var ErrHiddenOrMissing = errors.New("question not votable") + +// SetVote sets the user's vote to value (1, -1, or 0 to clear) on a visible question. +func SetVote(ctx context.Context, db *sql.DB, userID, questionID string, value int) error { + if value != 1 && value != -1 && value != 0 { + return fmt.Errorf("invalid vote") + } + q := sqlc.New(db) + if value == 0 { + visible, err := q.QuestionIsVisible(ctx, questionID) + if err != nil { + return err + } + if !visible { + return ErrHiddenOrMissing + } + return q.DeleteVote(ctx, sqlc.DeleteVoteParams{ + UserID: userID, + QuestionID: questionID, + }) + } + n, err := q.UpsertVoteOnVisible(ctx, sqlc.UpsertVoteOnVisibleParams{ + UserID: userID, + QuestionID: questionID, + Value: int32(value), + }) + if err != nil { + return mapUniqueViolation(err) + } + if n == 0 { + return ErrHiddenOrMissing + } + return nil +} + +// Vote is kept as an alias for SetVote for callers that still use the old name. +func Vote(ctx context.Context, db *sql.DB, userID, questionID string, value int) error { + return SetVote(ctx, db, userID, questionID, value) +} + +func mapUniqueViolation(err error) error { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return ErrDuplicateUsername + } + return err +} diff --git a/internal/web/admin.go b/internal/web/admin.go new file mode 100644 index 0000000..fd90212 --- /dev/null +++ b/internal/web/admin.go @@ -0,0 +1,96 @@ +package web + +import ( + "errors" + "net/http" + "net/url" + "strings" + + "github.com/go-chi/chi/v5" + + "plumber/internal/store" +) + +type adminUsersPage struct { + page + Users []store.User + Error string + Search string + NextCursor string + HasMore bool +} + +func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) *store.User { + u := currentUser(r) + if !u.Admin() { + http.Error(w, "forbidden", http.StatusForbidden) + return nil + } + return u +} + +func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) { + if s.requireAdmin(w, r) == nil { + return + } + search := strings.TrimSpace(r.URL.Query().Get("q")) + cursorCreated := r.URL.Query().Get("cursor_created") + cursorID := r.URL.Query().Get("cursor_id") + users, nextCreated, nextID, err := s.store.ListUsers(r.Context(), store.ListUsersQuery{ + Search: search, + CursorCreated: cursorCreated, + CursorID: cursorID, + Limit: store.AdminUsersLimit, + }) + if err != nil { + http.Error(w, "could not load users", http.StatusInternalServerError) + return + } + next := "" + if nextCreated != "" { + v := url.Values{} + if search != "" { + v.Set("q", search) + } + v.Set("cursor_created", nextCreated) + v.Set("cursor_id", nextID) + next = "/admin/users?" + v.Encode() + } + s.exec(w, "admin-users", adminUsersPage{ + page: s.basePage(r, "Users"), + Users: users, + Search: search, + NextCursor: next, + HasMore: next != "", + }) +} + +func (s *Server) handleAdminSetRole(w http.ResponseWriter, r *http.Request) { + if !s.requireCSRF(w, r) { + return + } + if s.requireAdmin(w, r) == nil { + return + } + id := chi.URLParam(r, "id") + role := store.Role(r.PostFormValue("role")) + err := s.store.SetUserRole(r.Context(), id, role) + if errors.Is(err, store.ErrLastAdmin) { + users, _, _, listErr := s.store.ListUsers(r.Context(), store.ListUsersQuery{Limit: store.AdminUsersLimit}) + if listErr != nil { + http.Error(w, "could not demote last admin", http.StatusBadRequest) + return + } + s.exec(w, "admin-users", adminUsersPage{ + page: s.basePage(r, "Users"), + Users: users, + Error: "Cannot demote the last admin.", + }) + return + } + if err != nil { + http.Error(w, "could not update role", http.StatusBadRequest) + return + } + http.Redirect(w, r, "/admin/users", http.StatusSeeOther) +} diff --git a/internal/web/auth.go b/internal/web/auth.go new file mode 100644 index 0000000..ed1d2c1 --- /dev/null +++ b/internal/web/auth.go @@ -0,0 +1,188 @@ +package web + +import ( + "crypto/subtle" + "database/sql" + "errors" + "log" + "net/http" + "net/url" + "regexp" + "strings" + "unicode/utf8" + + "golang.org/x/crypto/bcrypt" + + "plumber/internal/store" +) + +var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_]{3,20}$`) + +const ( + minPasswordRunes = 8 + maxPasswordBytes = 72 // bcrypt truncation limit +) + +// loginDummyHash is compared when the username is unknown so login timing +// does not reveal whether an account exists (same bcrypt cost as real hashes). +var loginDummyHash = mustBcrypt("timing-dummy-not-a-real-password") + +func mustBcrypt(s string) []byte { + h, err := bcrypt.GenerateFromPassword([]byte(s), bcrypt.DefaultCost) + if err != nil { + panic(err) + } + return h +} + +func safeNext(raw string) string { + if raw == "" { + return "/" + } + u, err := url.Parse(raw) + if err != nil || u.IsAbs() || !strings.HasPrefix(u.Path, "/") || strings.HasPrefix(u.Path, "//") { + return "/" + } + return u.RequestURI() +} + +func passwordValid(password string) (ok bool, msg string) { + if utf8.RuneCountInString(password) < minPasswordRunes { + return false, "Password must be at least 8 characters." + } + if len(password) > maxPasswordBytes { + return false, "Password must be at most 72 bytes." + } + return true, "" +} + +func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) { + if currentUser(r) != nil { + http.Redirect(w, r, safeNext(r.URL.Query().Get("next")), http.StatusSeeOther) + return + } + s.exec(w, "login", authPage{ + page: s.basePage(r, "Sign in"), + Next: r.URL.Query().Get("next"), + }) +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + if !s.requireCSRF(w, r) { + return + } + username := strings.TrimSpace(r.PostFormValue("username")) + password := r.PostFormValue("password") + next := safeNext(r.PostFormValue("next")) + userKey := store.NormalizeUsername(username) + ip := s.clientIP(r) + if !s.allowLoginAttempt(w, r, userKey) { + return + } + + u, err := s.store.UserByUsername(r.Context(), username) + hash := loginDummyHash + switch { + case err == nil: + hash = []byte(u.PasswordHash) + case errors.Is(err, sql.ErrNoRows): + // unknown user — still bcrypt against dummy hash + default: + log.Printf("login lookup: %v", err) + _ = bcrypt.CompareHashAndPassword(loginDummyHash, []byte(password)) + http.Error(w, "service temporarily unavailable", http.StatusServiceUnavailable) + return + } + if err != nil || bcrypt.CompareHashAndPassword(hash, []byte(password)) != nil { + s.loginFail.record(loginFailKey(ip, userKey)) + w.WriteHeader(http.StatusUnauthorized) + s.exec(w, "login", authPage{ + page: s.basePage(r, "Sign in"), + Username: username, + Next: next, + Error: "Wrong username or password.", + }) + return + } + s.loginFail.clear(loginFailKey(ip, userKey)) + if err := s.sessions.RenewToken(r.Context()); err != nil { + http.Error(w, "could not start session", http.StatusInternalServerError) + return + } + s.sessions.Put(r.Context(), "user_id", u.ID) + http.Redirect(w, r, next, http.StatusSeeOther) +} + +func (s *Server) handleRegisterForm(w http.ResponseWriter, r *http.Request) { + if currentUser(r) != nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + s.exec(w, "register", authPage{page: s.basePage(r, "Create account")}) +} + +func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { + if !s.requireCSRF(w, r) { + return + } + if !s.allowRegisterAttempt(w, r) { + return + } + username := strings.TrimSpace(r.PostFormValue("username")) + password := r.PostFormValue("password") + setupSecret := r.PostFormValue("setup_secret") + p := authPage{page: s.basePage(r, "Create account"), Username: username} + if !usernameRe.MatchString(username) { + p.Error = "Username must be 3–20 letters, numbers, or underscores." + s.exec(w, "register", p) + return + } + if ok, msg := passwordValid(password); !ok { + p.Error = msg + s.exec(w, "register", p) + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + http.Error(w, "could not save password", http.StatusInternalServerError) + return + } + role := store.RoleUser + if setupSecretMatches(s.cfg.AdminSetupSecret, setupSecret) { + role = store.RoleAdmin + } + u := &store.User{ + Username: username, + PasswordHash: string(hash), + Role: role, + } + if err := s.store.CreateUser(r.Context(), u); err != nil { + if errors.Is(err, store.ErrDuplicateUsername) { + p.Error = "That username is taken." + s.exec(w, "register", p) + return + } + log.Printf("register create: %v", err) + http.Error(w, "could not create account", http.StatusInternalServerError) + return + } + if err := s.sessions.RenewToken(r.Context()); err != nil { + log.Printf("register session: %v", err) + s.sessions.Put(r.Context(), "flash", "Account created — please sign in.") + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + s.sessions.Put(r.Context(), "user_id", u.ID) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func setupSecretMatches(want, provided string) bool { + if want == "" || provided == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(provided), []byte(want)) == 1 +} + +func (s *Server) handleAuthPrompt(w http.ResponseWriter, r *http.Request) { + s.exec(w, "signin-prompt", s.basePage(r, "")) +} diff --git a/internal/web/auth_test.go b/internal/web/auth_test.go new file mode 100644 index 0000000..83a11cd --- /dev/null +++ b/internal/web/auth_test.go @@ -0,0 +1,57 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestPasswordMaxBytes(t *testing.T) { + if ok, _ := passwordValid(strings.Repeat("a", 8)); !ok { + t.Fatal("8 ascii runes should pass") + } + if ok, msg := passwordValid(strings.Repeat("a", 73)); ok || !strings.Contains(msg, "72") { + t.Fatalf("73 bytes should fail: ok=%v msg=%q", ok, msg) + } +} + +func TestLogoutDestroysSession(t *testing.T) { + srv, _ := newTestServer(t, Config{}) + h := srv.Handler() + name := uniq("out") + cookies := registerUser(t, h, name, "hunter22") + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/profile", nil) + for _, c := range cookies { + req.AddCookie(c) + } + h.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("profile before logout %d", rec.Code) + } + csrf := csrfFrom(rec.Body.String()) + form := strings.NewReader("_csrf=" + csrf) + req = httptest.NewRequest(http.MethodPost, "/logout", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for _, c := range cookies { + req.AddCookie(c) + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Fatalf("logout %d", rec.Code) + } + postLogout := mergeCookies(cookies, rec.Result().Cookies()) + + req = httptest.NewRequest(http.MethodGet, "/profile", nil) + for _, c := range postLogout { + req.AddCookie(c) + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Fatalf("profile after logout should redirect, got %d", rec.Code) + } +} diff --git a/internal/web/helpers_test.go b/internal/web/helpers_test.go new file mode 100644 index 0000000..434cc61 --- /dev/null +++ b/internal/web/helpers_test.go @@ -0,0 +1,121 @@ +package web + +import ( + "bytes" + "encoding/binary" + "hash/crc32" + "image" + "image/jpeg" + "image/png" + "strings" + "testing" +) + +func TestTruncateRunes(t *testing.T) { + tests := []struct { + in string + max int + want string + }{ + {"abc", 10, "abc"}, + {"abcdef", 3, "abc"}, + {"héllo", 3, "hél"}, + {"🙂🙂🙂", 2, "🙂🙂"}, + {"世界和平", 2, "世界"}, + {"abc", 0, ""}, + {"abc", -1, ""}, + {"", 5, ""}, + } + for _, tc := range tests { + if got := truncateRunes(tc.in, tc.max); got != tc.want { + t.Fatalf("truncateRunes(%q, %d)=%q want %q", tc.in, tc.max, got, tc.want) + } + } +} + +func TestPrepareAvatar(t *testing.T) { + var pngBuf bytes.Buffer + if err := png.Encode(&pngBuf, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil { + t.Fatal(err) + } + var jpegBuf bytes.Buffer + if err := jpeg.Encode(&jpegBuf, image.NewRGBA(image.Rect(0, 0, 2, 2)), &jpeg.Options{Quality: 90}); err != nil { + t.Fatal(err) + } + var largePNG bytes.Buffer + if err := png.Encode(&largePNG, image.NewRGBA(image.Rect(0, 0, 800, 600))); err != nil { + t.Fatal(err) + } + + oversized := bytes.Repeat([]byte{0x89}, (2<<20)+2) + + tests := []struct { + name string + in []byte + max int64 + wantExt string + wantErr string + }{ + {name: "png", in: pngBuf.Bytes(), max: 2 << 20, wantExt: ".png"}, + {name: "jpeg", in: jpegBuf.Bytes(), max: 2 << 20, wantExt: ".jpg"}, + {name: "resize large", in: largePNG.Bytes(), max: 2 << 20, wantExt: ".png"}, + {name: "empty", in: nil, max: 2 << 20, wantErr: "empty"}, + {name: "invalid", in: []byte("not-an-image"), max: 2 << 20, wantErr: "unsupported"}, + {name: "oversized", in: oversized, max: 2 << 20, wantErr: "too large"}, + {name: "huge dims", in: pngWithDims(100000, 100000), max: 2 << 20, wantErr: "dimensions"}, + {name: "over decode cap", in: pngWithDims(2048, 2048), max: 2 << 20, wantErr: "dimensions"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + body, ext, ct, err := prepareAvatar(bytes.NewReader(tc.in), tc.max) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err=%v want substring %q", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatal(err) + } + if ext != tc.wantExt { + t.Fatalf("ext=%q want %q", ext, tc.wantExt) + } + if len(body) == 0 || ct == "" { + t.Fatalf("empty output body/ct") + } + if int64(len(body)) > tc.max { + t.Fatalf("encoded size %d exceeds max %d", len(body), tc.max) + } + cfg, _, err := image.DecodeConfig(bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + if cfg.Width > 512 || cfg.Height > 512 { + t.Fatalf("avatar dims %dx%d exceed 512", cfg.Width, cfg.Height) + } + }) + } +} + +func pngWithDims(w, h int) []byte { + var buf bytes.Buffer + buf.Write([]byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}) + var ihdr bytes.Buffer + _ = binary.Write(&ihdr, binary.BigEndian, uint32(w)) + _ = binary.Write(&ihdr, binary.BigEndian, uint32(h)) + ihdr.Write([]byte{8, 2, 0, 0, 0}) // bit depth, color type, compression, filter, interlace + writePNGChunk(&buf, "IHDR", ihdr.Bytes()) + writePNGChunk(&buf, "IDAT", []byte{0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x00, 0x03, 0x00, 0x01}) + writePNGChunk(&buf, "IEND", nil) + return buf.Bytes() +} + +func writePNGChunk(buf *bytes.Buffer, name string, data []byte) { + _ = binary.Write(buf, binary.BigEndian, uint32(len(data))) + buf.WriteString(name) + buf.Write(data) + crc := crc32.NewIEEE() + _, _ = crc.Write([]byte(name)) + _, _ = crc.Write(data) + _ = binary.Write(buf, binary.BigEndian, crc.Sum32()) +} diff --git a/internal/web/profile.go b/internal/web/profile.go new file mode 100644 index 0000000..d76a2db --- /dev/null +++ b/internal/web/profile.go @@ -0,0 +1,267 @@ +package web + +import ( + "bytes" + "fmt" + "image" + "image/jpeg" + "image/png" + "io" + "net/http" + "path" + "strings" + + "golang.org/x/image/draw" + _ "golang.org/x/image/webp" + + "plumber/internal/blob" + "plumber/internal/geo" + "plumber/internal/store" +) + +type profilePage struct { + page + States []struct{ Code, Name string } + Questions []store.RankedQuestion + QuestionsLabel string + UploadsEnabled bool + Error string + StateVal string +} + +func (s *Server) handleProfileForm(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + if u == nil { + http.Redirect(w, r, "/login?next=/profile", http.StatusSeeOther) + return + } + s.renderProfile(w, r, u, "", u.State) +} + +func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + if u == nil { + http.Redirect(w, r, "/login?next=/profile", http.StatusSeeOther) + return + } + if err := r.ParseMultipartForm(3 << 20); err != nil { + s.renderProfile(w, r, u, "Could not read form (max 2MB for images).", u.State) + return + } + want := s.sessions.GetString(r.Context(), "csrf") + got := r.FormValue("_csrf") + if want == "" || got != want { + http.Error(w, "invalid csrf token", http.StatusForbidden) + return + } + + state := geo.NormalizeState(r.FormValue("state")) + if !geo.ValidState(state) { + s.renderProfile(w, r, u, "Choose a valid US state or leave it blank.", state) + return + } + + avatarURL := "" + avatarKey := "" + file, hdr, err := r.FormFile("avatar") + if err == nil { + defer file.Close() + if !s.cfg.Blob.Enabled() { + s.renderProfile(w, r, u, "Avatar uploads are not configured on this server.", state) + return + } + if hdr.Size > 2<<20 { + s.renderProfile(w, r, u, "Avatar must be 2MB or smaller.", state) + return + } + body, ext, contentType, prepErr := prepareAvatar(file, 2<<20) + if prepErr != nil { + s.renderProfile(w, r, u, "Avatar must be a JPEG, PNG, or WebP image.", state) + return + } + prevURL := u.AvatarURL + avatarKey = path.Join("avatars", u.ID, "avatar"+ext) + url, upErr := s.cfg.Blob.Upload(r.Context(), blob.FileUpload{ + Key: avatarKey, + Body: bytes.NewReader(body), + ContentType: contentType, + Size: int64(len(body)), + }) + if upErr != nil { + s.renderProfile(w, r, u, "Could not upload avatar. Try again later.", state) + return + } + avatarURL = url + u.State = state + u.AvatarURL = avatarURL + if err := s.store.SaveUserProfile(r.Context(), u); err != nil { + _ = s.cfg.Blob.Delete(r.Context(), avatarKey) + http.Error(w, "could not save profile", http.StatusInternalServerError) + return + } + if oldKey := avatarObjectKey(prevURL, u.ID); oldKey != "" && oldKey != avatarKey { + _ = s.cfg.Blob.Delete(r.Context(), oldKey) + } + s.sessions.Put(r.Context(), "flash", "Profile saved.") + http.Redirect(w, r, "/profile", http.StatusSeeOther) + return + } else if err != http.ErrMissingFile { + s.renderProfile(w, r, u, "Could not read avatar file.", state) + return + } + + u.State = state + if err := s.store.SaveUserProfile(r.Context(), u); err != nil { + http.Error(w, "could not save profile", http.StatusInternalServerError) + return + } + s.sessions.Put(r.Context(), "flash", "Profile saved.") + http.Redirect(w, r, "/profile", http.StatusSeeOther) +} + +func avatarObjectKey(publicURL, userID string) string { + marker := "/avatars/" + userID + "/" + i := strings.Index(publicURL, marker) + if i < 0 { + return "" + } + rest := publicURL[i+1:] // avatars/... + if q := strings.IndexAny(rest, "?#"); q >= 0 { + rest = rest[:q] + } + return rest +} + +// prepareAvatar reads at most maxBytes, sniffs/decodes the image, resizes to a +// small avatar, and re-encodes so only bounded valid image bytes are stored. +func prepareAvatar(r io.Reader, maxBytes int64) (body []byte, ext, contentType string, err error) { + limited := io.LimitReader(r, maxBytes+1) + raw, err := io.ReadAll(limited) + if err != nil { + return nil, "", "", err + } + if int64(len(raw)) > maxBytes { + return nil, "", "", fmt.Errorf("avatar too large") + } + if len(raw) == 0 { + return nil, "", "", fmt.Errorf("empty avatar") + } + + sniff := http.DetectContentType(raw) + switch { + case strings.HasPrefix(sniff, "image/jpeg"), + strings.HasPrefix(sniff, "image/png"), + strings.HasPrefix(sniff, "image/webp"): + default: + return nil, "", "", fmt.Errorf("unsupported type %s", sniff) + } + + cfg, format, err := image.DecodeConfig(bytes.NewReader(raw)) + if err != nil { + return nil, "", "", err + } + // Cap decoded size before allocating pixel buffers (~4 MiB RGBA at 1024²). + const maxDecodeDim = 1024 + const maxPixels = maxDecodeDim * maxDecodeDim + if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > maxDecodeDim || cfg.Height > maxDecodeDim { + return nil, "", "", fmt.Errorf("image dimensions out of range") + } + if int64(cfg.Width)*int64(cfg.Height) > maxPixels { + return nil, "", "", fmt.Errorf("image too many pixels") + } + + img, decodedFormat, err := image.Decode(bytes.NewReader(raw)) + if err != nil { + return nil, "", "", err + } + if format != "" { + decodedFormat = format + } + + const maxAvatarDim = 512 + img = fitAvatar(img, maxAvatarDim) + + var out bytes.Buffer + switch decodedFormat { + case "jpeg": + if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: 85}); err != nil { + return nil, "", "", err + } + if int64(out.Len()) > maxBytes { + return nil, "", "", fmt.Errorf("encoded avatar too large") + } + return out.Bytes(), ".jpg", "image/jpeg", nil + case "png", "webp": + if err := png.Encode(&out, img); err != nil { + return nil, "", "", err + } + if int64(out.Len()) > maxBytes { + // Fall back to JPEG when PNG balloons past the upload cap. + out.Reset() + if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: 85}); err != nil { + return nil, "", "", err + } + if int64(out.Len()) > maxBytes { + return nil, "", "", fmt.Errorf("encoded avatar too large") + } + return out.Bytes(), ".jpg", "image/jpeg", nil + } + return out.Bytes(), ".png", "image/png", nil + default: + return nil, "", "", fmt.Errorf("unsupported format %s", decodedFormat) + } +} + +// fitAvatar scales img down so both sides are at most maxDim. +func fitAvatar(img image.Image, maxDim int) image.Image { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + if w <= maxDim && h <= maxDim { + return img + } + scale := float64(maxDim) / float64(w) + if float64(h)*scale > float64(maxDim) { + scale = float64(maxDim) / float64(h) + } + nw := int(float64(w) * scale) + nh := int(float64(h) * scale) + if nw < 1 { + nw = 1 + } + if nh < 1 { + nh = 1 + } + dst := image.NewRGBA(image.Rect(0, 0, nw, nh)) + draw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Over, nil) + return dst +} + +func (s *Server) renderProfile(w http.ResponseWriter, r *http.Request, u *store.User, errMsg, stateVal string) { + var ( + questions []store.RankedQuestion + label string + err error + ) + if u.Admin() { + label = "Questions you answered" + questions, err = s.store.ListQuestionsAnsweredBy(r.Context(), u.ID) + } else { + label = "Your questions" + questions, err = s.store.ListQuestionsByAuthor(r.Context(), u.ID) + } + if err != nil { + http.Error(w, "could not load questions", http.StatusInternalServerError) + return + } + p := s.basePage(r, "Profile") + p.User = u + s.exec(w, "profile", profilePage{ + page: p, + States: geo.States, + Questions: questions, + QuestionsLabel: label, + UploadsEnabled: s.cfg.Blob.Enabled(), + Error: errMsg, + StateVal: stateVal, + }) +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..7950bc6 --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,577 @@ +package web + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "html/template" + "io/fs" + "log" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/alexedwards/scs/v2" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "plumber/internal/blob" + "plumber/internal/geo" + "plumber/internal/pacific" + "plumber/internal/store" +) + +type Config struct { + // AdminSetupSecret, when set, can promote the first registrant who also + // posts the matching setup_secret. It is ignored once any admin exists. + AdminSetupSecret string + SecureCookie bool + // TrustedProxies are CIDRs allowed to set X-Forwarded-For (direct peer). + TrustedProxies []*net.IPNet + Blob blob.Uploader +} + +type Server struct { + store store.Store + sessions *scs.SessionManager + tmpl *template.Template + cfg Config + static http.Handler + loginIP *throttle + registerIP *throttle + loginFail *failureTracker +} + +type page struct { + User *store.User + CSRF string + Flash string + Title string + Today string + Yesterday string +} + +type huntPage struct { + page + Date string + Label string + IsToday bool + IsYesterday bool + Questions []store.RankedQuestion +} + +type questionPage struct { + page + Question *store.RankedQuestion + Answer *store.Answer +} + +type submitPage struct { + page + TitleVal string + BodyVal string + CityVal string + Error string +} + +type authPage struct { + page + Username string + Error string + Next string +} + +type voteCtx struct { + User *store.User + CSRF string + View string + Date string + Question store.RankedQuestion +} + +func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.FS, cfg Config) (*Server, error) { + if cfg.Blob == nil { + cfg.Blob = blob.Disabled{} + } + funcMap := template.FuncMap{ + "voteCtx": func(user *store.User, csrf, view, date string, q store.RankedQuestion) voteCtx { + return voteCtx{User: user, CSRF: csrf, View: view, Date: date, Question: q} + }, + "add": func(a, b int) int { return a + b }, + "rank": func(i int) int { return i + 1 }, + "isAdmin": func(u *store.User) bool { return u.Admin() }, + "pacificLabel": pacific.Label, + "locationTag": func(u *store.User) string { + if u != nil { + if name := geo.StateName(u.State); name != "" { + return name + } + } + return "Bay Area" + }, + } + tmpl, err := template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html", "templates/partials/*.html") + if err != nil { + return nil, fmt.Errorf("parse templates: %w", err) + } + sessions := scs.New() + sessions.Store = sessionStore + sessions.Lifetime = 30 * 24 * time.Hour + sessions.Cookie.Name = "plumber_session" + sessions.Cookie.HttpOnly = true + sessions.Cookie.SameSite = http.SameSiteLaxMode + sessions.Cookie.Secure = cfg.SecureCookie + sessions.Cookie.Path = "/" + + sub, err := fs.Sub(staticFS, "static") + if err != nil { + return nil, err + } + + return &Server{ + store: st, + sessions: sessions, + tmpl: tmpl, + cfg: cfg, + static: http.StripPrefix("/static/", http.FileServer(http.FS(sub))), + loginIP: newThrottle(20, 15*time.Minute, defaultThrottleMaxKeys), + registerIP: newThrottle(10, 15*time.Minute, defaultThrottleMaxKeys), + loginFail: newFailureTracker(15*time.Minute, defaultThrottleMaxKeys), + }, nil +} + +func (s *Server) Handler() http.Handler { + r := chi.NewRouter() + r.Use(middleware.RequestID) + // Do not use middleware.RealIP: it rewrites RemoteAddr from client-controlled + // forwarding headers before clientIP can validate the TCP peer against + // TrustedProxies. clientIP walks X-Forwarded-For itself when the peer is trusted. + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 3<<20) + next.ServeHTTP(w, r) + }) + }) + r.Use(s.sessions.LoadAndSave) + r.Use(s.withUser) + r.Handle("/static/*", s.static) + r.Get("/", s.handleToday) + r.Get("/archive", s.handleArchive) + r.Get("/hunt/{date}", s.handleHunt) + r.Get("/submit", s.handleSubmitForm) + r.Post("/submit", s.handleSubmit) + r.Get("/questions/{id}", s.handleQuestion) + r.Post("/questions/{id}/vote", s.handleVote) + r.Post("/questions/{id}/answer", s.handleAnswer) + r.Post("/questions/{id}/hide", s.handleHide) + r.Get("/login", s.handleLoginForm) + r.Post("/login", s.handleLogin) + r.Get("/register", s.handleRegisterForm) + r.Post("/register", s.handleRegister) + r.Get("/auth/prompt", s.handleAuthPrompt) + r.Post("/logout", s.handleLogout) + r.Get("/admin/users", s.handleAdminUsers) + r.Post("/admin/users/{id}/role", s.handleAdminSetRole) + r.Get("/profile", s.handleProfileForm) + r.Post("/profile", s.handleProfile) + return r +} + +type ctxKey int + +const userKey ctxKey = 1 + +func (s *Server) withUser(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.sessions.GetString(r.Context(), "csrf") == "" { + s.sessions.Put(r.Context(), "csrf", randomHex(16)) + } + id := s.sessions.GetString(r.Context(), "user_id") + if id != "" { + u, err := s.store.UserByID(r.Context(), id) + if err == nil { + r = r.WithContext(context.WithValue(r.Context(), userKey, u)) + } + } + next.ServeHTTP(w, r) + }) +} + +func currentUser(r *http.Request) *store.User { + u, _ := r.Context().Value(userKey).(*store.User) + return u +} + +func (s *Server) basePage(r *http.Request, title string) page { + return page{ + User: currentUser(r), + CSRF: s.sessions.GetString(r.Context(), "csrf"), + Flash: s.sessions.PopString(r.Context(), "flash"), + Title: title, + Today: pacific.Today(), + Yesterday: pacific.Yesterday(), + } +} + +func isHTMX(r *http.Request) bool { + return r.Header.Get("HX-Request") == "true" +} + +func (s *Server) requireCSRF(w http.ResponseWriter, r *http.Request) bool { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return false + } + want := s.sessions.GetString(r.Context(), "csrf") + got := r.PostFormValue("_csrf") + if want == "" || got != want { + http.Error(w, "invalid csrf token", http.StatusForbidden) + return false + } + return true +} + +func (s *Server) handleToday(w http.ResponseWriter, r *http.Request) { + s.renderHunt(w, r, pacific.Today()) +} + +func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) { + date := r.URL.Query().Get("date") + if date == "" || date == pacific.Today() { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + if _, err := pacific.Parse(date); err != nil || date > pacific.Today() { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + http.Redirect(w, r, "/hunt/"+url.PathEscape(date), http.StatusSeeOther) +} + +func (s *Server) handleHunt(w http.ResponseWriter, r *http.Request) { + date := chi.URLParam(r, "date") + if _, err := pacific.Parse(date); err != nil { + http.NotFound(w, r) + return + } + if date >= pacific.Today() { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + s.renderHunt(w, r, date) +} + +func (s *Server) renderHunt(w http.ResponseWriter, r *http.Request, date string) { + viewer := "" + if u := currentUser(r); u != nil { + viewer = u.ID + } + questions, err := s.store.ListHunt(r.Context(), date, viewer) + if err != nil { + http.Error(w, "could not load questions", http.StatusInternalServerError) + return + } + label := pacific.Label(date) + title := label + if pacific.IsToday(date) { + title = "Today" + } + s.exec(w, "hunt", huntPage{ + page: s.basePage(r, title), + Date: date, + Label: label, + IsToday: pacific.IsToday(date), + IsYesterday: pacific.IsYesterday(date), + Questions: questions, + }) +} + +func (s *Server) handleSubmitForm(w http.ResponseWriter, r *http.Request) { + if currentUser(r) == nil { + s.sessions.Put(r.Context(), "flash", "Sign in to ask a question.") + http.Redirect(w, r, "/login?next=/submit", http.StatusSeeOther) + return + } + s.exec(w, "submit", submitPage{page: s.basePage(r, "Ask a question")}) +} + +func (s *Server) handleSubmit(w http.ResponseWriter, r *http.Request) { + if !s.requireCSRF(w, r) { + return + } + u := currentUser(r) + if u == nil { + http.Redirect(w, r, "/login?next=/submit", http.StatusSeeOther) + return + } + title := strings.TrimSpace(r.PostFormValue("title")) + body := strings.TrimSpace(r.PostFormValue("body")) + city := strings.TrimSpace(r.PostFormValue("city")) + if title == "" || body == "" { + s.exec(w, "submit", submitPage{ + page: s.basePage(r, "Ask a question"), + TitleVal: title, + BodyVal: body, + CityVal: city, + Error: "Title and description are required.", + }) + return + } + if len(title) > 120 { + title = truncateRunes(title, 120) + } + if len(body) > 8000 { + body = truncateRunes(body, 8000) + } + if len(city) > 80 { + city = truncateRunes(city, 80) + } + q := &store.RankedQuestion{ + AuthorID: u.ID, + Title: title, + Body: body, + City: city, + } + if err := s.store.CreateQuestion(r.Context(), q); err != nil { + http.Error(w, "could not save question", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/questions/"+url.PathEscape(q.ID), http.StatusSeeOther) +} + +func (s *Server) handleQuestion(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + viewer := "" + if u := currentUser(r); u != nil { + viewer = u.ID + } + q, err := s.store.GetQuestion(r.Context(), id, viewer) + if err != nil || (q.Hidden && !currentUser(r).Admin()) { + http.NotFound(w, r) + return + } + var ans *store.Answer + if q.Answered { + ans, err = s.store.GetAnswer(r.Context(), q.ID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + log.Printf("question %s marked answered but answer missing", q.ID) + http.Error(w, "answer unavailable", http.StatusInternalServerError) + return + } + log.Printf("get answer %s: %v", q.ID, err) + http.Error(w, "could not load answer", http.StatusInternalServerError) + return + } + } + s.exec(w, "question", questionPage{ + page: s.basePage(r, q.Title), + Question: q, + Answer: ans, + }) +} + +func (s *Server) handleVote(w http.ResponseWriter, r *http.Request) { + if !s.requireCSRF(w, r) { + return + } + u := currentUser(r) + if u == nil { + if isHTMX(r) { + s.exec(w, "signin-prompt", s.basePage(r, "")) + return + } + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + id := chi.URLParam(r, "id") + value := 0 + switch r.PostFormValue("value") { + case "1": + value = 1 + case "-1": + value = -1 + case "0": + value = 0 + default: + http.Error(w, "invalid vote", http.StatusBadRequest) + return + } + if err := s.store.Vote(r.Context(), u.ID, id, value); err != nil { + if errors.Is(err, store.ErrHiddenOrMissing) { + http.Error(w, "not found", http.StatusNotFound) + return + } + http.Error(w, "could not vote", http.StatusInternalServerError) + return + } + view := r.PostFormValue("view") + date := r.PostFormValue("date") + if isHTMX(r) { + if view == "list" { + s.renderLeaderboard(w, r, date) + return + } + q, err := s.store.GetQuestion(r.Context(), id, u.ID) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + s.exec(w, "vote", voteCtx{ + User: u, + CSRF: s.sessions.GetString(r.Context(), "csrf"), + View: "question", + Date: q.HuntDate, + Question: *q, + }) + return + } + if view == "question" { + http.Redirect(w, r, "/questions/"+url.PathEscape(id), http.StatusSeeOther) + return + } + if date != "" && date != pacific.Today() { + http.Redirect(w, r, "/hunt/"+url.PathEscape(date), http.StatusSeeOther) + return + } + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (s *Server) renderLeaderboard(w http.ResponseWriter, r *http.Request, date string) { + if date == "" { + date = pacific.Today() + } + viewer := "" + if u := currentUser(r); u != nil { + viewer = u.ID + } + questions, err := s.store.ListHunt(r.Context(), date, viewer) + if err != nil { + http.Error(w, "could not load questions", http.StatusInternalServerError) + return + } + s.exec(w, "leaderboard", huntPage{ + page: s.basePage(r, ""), + Date: date, + Questions: questions, + }) +} + +func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) { + if !s.requireCSRF(w, r) { + return + } + u := currentUser(r) + if !u.Admin() { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + id := chi.URLParam(r, "id") + body := strings.TrimSpace(r.PostFormValue("body")) + if body == "" { + http.Error(w, "answer required", http.StatusBadRequest) + return + } + if len(body) > 12000 { + body = truncateRunes(body, 12000) + } + ans := &store.Answer{ + QuestionID: id, + AuthorID: u.ID, + Body: body, + } + if err := s.store.UpsertAnswer(r.Context(), ans); err != nil { + http.Error(w, "could not save answer", http.StatusInternalServerError) + return + } + saved, err := s.store.GetAnswer(r.Context(), id) + if err != nil { + http.Error(w, "could not load answer", http.StatusInternalServerError) + return + } + if isHTMX(r) { + s.exec(w, "answer", questionPage{page: s.basePage(r, ""), Answer: saved}) + return + } + http.Redirect(w, r, "/questions/"+url.PathEscape(id), http.StatusSeeOther) +} + +func (s *Server) handleHide(w http.ResponseWriter, r *http.Request) { + if !s.requireCSRF(w, r) { + return + } + u := currentUser(r) + if !u.Admin() { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + id := chi.URLParam(r, "id") + q, err := s.store.GetQuestion(r.Context(), id, u.ID) + if err != nil { + http.NotFound(w, r) + return + } + if err := s.store.HideQuestion(r.Context(), id); err != nil { + http.Error(w, "could not hide", http.StatusInternalServerError) + return + } + if isHTMX(r) && r.PostFormValue("view") == "list" { + s.renderLeaderboard(w, r, q.HuntDate) + return + } + if isHTMX(r) { + w.Header().Set("HX-Redirect", "/") + w.WriteHeader(http.StatusSeeOther) + return + } + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + if !s.requireCSRF(w, r) { + return + } + if err := s.sessions.Destroy(r.Context()); err != nil { + http.Error(w, "could not sign out", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (s *Server) exec(w http.ResponseWriter, name string, data any) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil { + log.Printf("template %s: %v", name, err) + http.Error(w, "template error", http.StatusInternalServerError) + } +} + +// truncateRunes shortens s to at most max runes without splitting a code point. +func truncateRunes(s string, max int) string { + if max <= 0 { + return "" + } + n := 0 + for byteIdx := range s { + if n == max { + return s[:byteIdx] + } + n++ + } + return s +} + +func randomHex(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return hex.EncodeToString(b) +} diff --git a/internal/web/server_test.go b/internal/web/server_test.go new file mode 100644 index 0000000..7419318 --- /dev/null +++ b/internal/web/server_test.go @@ -0,0 +1,716 @@ +package web + +import ( + "bytes" + "context" + "image" + "image/png" + "mime/multipart" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/alexedwards/scs/v2/memstore" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" + + "plumber" + "plumber/internal/blob" + "plumber/internal/pacific" + "plumber/internal/store" +) + +func newTestServer(t *testing.T, cfg Config) (*Server, *store.Memory) { + t.Helper() + mem := store.NewMemory() + return newTestServerStore(t, mem, cfg), mem +} + +func newTestServerStore(t *testing.T, st store.Store, cfg Config) *Server { + t.Helper() + if cfg.Blob == nil { + cfg.Blob = blob.Disabled{} + } + srv, err := New(st, memstore.New(), plumber.TemplateFS, plumber.StaticFS, cfg) + if err != nil { + t.Fatal(err) + } + return srv +} + +func uniq(prefix string) string { + return prefix + "_" + strings.ReplaceAll(uuid.NewString()[:8], "-", "") +} + +func seedUser(t *testing.T, st store.Store, username, password string, role store.Role) *store.User { + t.Helper() + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + u := &store.User{ + Username: username, + PasswordHash: string(hash), + Role: role, + } + if err := st.CreateUser(context.Background(), u); err != nil { + t.Fatal(err) + } + return u +} + +func sessionValue(cookies []*http.Cookie) string { + for _, c := range cookies { + if c.Name == "plumber_session" { + return c.Value + } + } + return "" +} + +func loginUser(t *testing.T, h http.Handler, username, password string) []*http.Cookie { + t.Helper() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil)) + pre := rec.Result().Cookies() + preToken := sessionValue(pre) + csrf := csrfFrom(rec.Body.String()) + form := strings.NewReader("_csrf=" + csrf + "&username=" + username + "&password=" + password) + req := httptest.NewRequest(http.MethodPost, "/login", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for _, c := range pre { + req.AddCookie(c) + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Fatalf("login %s: %d %s", username, rec.Code, rec.Body.String()) + } + post := mergeCookies(pre, rec.Result().Cookies()) + postToken := sessionValue(post) + if preToken == "" || postToken == "" || preToken == postToken { + t.Fatalf("expected session token rotation on login; pre=%q post=%q", preToken, postToken) + } + // Old anonymous token must not unlock authenticated routes. + req = httptest.NewRequest(http.MethodGet, "/profile", nil) + for _, c := range pre { + req.AddCookie(c) + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Fatalf("pre-auth cookie should not access profile, got %d", rec.Code) + } + return post +} + +func registerUser(t *testing.T, h http.Handler, username, password string, setupSecret ...string) []*http.Cookie { + t.Helper() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/register", nil)) + pre := rec.Result().Cookies() + preToken := sessionValue(pre) + csrf := csrfFrom(rec.Body.String()) + form := "_csrf=" + csrf + "&username=" + username + "&password=" + password + if len(setupSecret) > 0 && setupSecret[0] != "" { + form += "&setup_secret=" + setupSecret[0] + } + req := httptest.NewRequest(http.MethodPost, "/register", strings.NewReader(form)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for _, c := range pre { + req.AddCookie(c) + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Fatalf("register %s: %d %s", username, rec.Code, rec.Body.String()) + } + post := mergeCookies(pre, rec.Result().Cookies()) + postToken := sessionValue(post) + if preToken == "" || postToken == "" || preToken == postToken { + t.Fatalf("expected session token rotation on register; pre=%q post=%q", preToken, postToken) + } + return post +} + +func TestHomeEmptyAndViewport(t *testing.T) { + srv, _ := newTestServer(t, Config{}) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + srv.Handler().ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + if !strings.Contains(body, "width=device-width") { + t.Fatal("missing mobile viewport") + } + if !strings.Contains(body, "not a substitute for a licensed plumber") { + t.Fatal("missing disclaimer") + } +} + +func TestRegisterLoginAsk(t *testing.T) { + srv, _ := newTestServer(t, Config{}) + h := srv.Handler() + name := uniq("ask") + session := registerUser(t, h, name, "hunter22") + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/submit", nil) + for _, c := range session { + req.AddCookie(c) + } + h.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("submit form %d", rec.Code) + } + csrf := csrfFrom(rec.Body.String()) + form := strings.NewReader("_csrf=" + csrf + "&title=Leaky+faucet&body=Drip+all+night.&city=Oakland") + req = httptest.NewRequest(http.MethodPost, "/submit", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for _, c := range session { + req.AddCookie(c) + } + for _, c := range rec.Result().Cookies() { + req.AddCookie(c) + } + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req) + if rec2.Code != http.StatusSeeOther { + t.Fatalf("submit %d %s", rec2.Code, rec2.Body.String()) + } +} + +func TestAdminSetupSecretOnlyWhenNoAdmins(t *testing.T) { + mem := store.NewMemory() + secret := "one-time-admin-setup-secret" + adminName := uniq("seed") + srv := newTestServerStore(t, mem, Config{AdminSetupSecret: secret}) + h := srv.Handler() + + plain := uniq("plain") + registerUser(t, h, plain, "hunter22") + uPlain, err := mem.UserByUsername(context.Background(), plain) + if err != nil || uPlain.Admin() { + t.Fatalf("register without setup secret must stay user: %+v %v", uPlain, err) + } + + registerUser(t, h, adminName, "hunter22", secret) + u, err := mem.UserByUsername(context.Background(), adminName) + if err != nil || !u.Admin() { + t.Fatalf("setup secret registrant should be admin: %+v %v", u, err) + } + + later := uniq("later") + srv2 := newTestServerStore(t, mem, Config{AdminSetupSecret: secret}) + registerUser(t, srv2.Handler(), later, "hunter22", secret) + u2, err := mem.UserByUsername(context.Background(), later) + if err != nil { + t.Fatal(err) + } + if u2.Admin() { + t.Fatal("setup secret must not grant admin once an admin already exists") + } +} + +func TestAdminUsersPageAccessAndRoles(t *testing.T) { + srv, mem := newTestServer(t, Config{}) + h := srv.Handler() + hubName := uniq("hub") + bobName := uniq("bob") + carolName := uniq("carol") + seedUser(t, mem, hubName, "hunter22", store.RoleAdmin) + adminCookies := loginUser(t, h, hubName, "hunter22") + registerUser(t, h, bobName, "hunter22") + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/admin/users", nil) + for _, c := range adminCookies { + req.AddCookie(c) + } + h.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("admin list %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), bobName) { + t.Fatal("missing bob on admin page") + } + + bob, err := mem.UserByUsername(context.Background(), bobName) + if err != nil { + t.Fatal(err) + } + csrf := csrfFrom(rec.Body.String()) + form := strings.NewReader("_csrf=" + csrf + "&role=admin") + req = httptest.NewRequest(http.MethodPost, "/admin/users/"+bob.ID+"/role", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for _, c := range adminCookies { + req.AddCookie(c) + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Fatalf("promote %d %s", rec.Code, rec.Body.String()) + } + bob, _ = mem.UserByUsername(context.Background(), bobName) + if !bob.Admin() { + t.Fatal("bob should be admin") + } + + carolCookies := registerUser(t, h, carolName, "hunter22") + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/admin/users", nil) + for _, c := range carolCookies { + req.AddCookie(c) + } + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("non-admin expected 403, got %d", rec.Code) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/admin/users", nil) + for _, c := range adminCookies { + req.AddCookie(c) + } + h.ServeHTTP(rec, req) + csrf = csrfFrom(rec.Body.String()) + form = strings.NewReader("_csrf=" + csrf + "&role=user") + req = httptest.NewRequest(http.MethodPost, "/admin/users/"+bob.ID+"/role", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for _, c := range adminCookies { + req.AddCookie(c) + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Fatalf("demote bob %d", rec.Code) + } + + hub, err := mem.UserByUsername(context.Background(), hubName) + if err != nil { + t.Fatal(err) + } + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/admin/users", nil) + for _, c := range adminCookies { + req.AddCookie(c) + } + h.ServeHTTP(rec, req) + csrf = csrfFrom(rec.Body.String()) + form = strings.NewReader("_csrf=" + csrf + "&role=user") + req = httptest.NewRequest(http.MethodPost, "/admin/users/"+hub.ID+"/role", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for _, c := range adminCookies { + req.AddCookie(c) + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("demote last admin expected page with error, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Cannot demote the last admin") { + t.Fatalf("missing last-admin error: %s", rec.Body.String()) + } + hub, _ = mem.UserByUsername(context.Background(), hubName) + if !hub.Admin() { + t.Fatal("hub must remain admin") + } +} + +type fakeBlob struct { + calls int + last string +} + +func (f *fakeBlob) Enabled() bool { return true } + +func (f *fakeBlob) Upload(_ context.Context, obj blob.FileUpload) (string, error) { + f.calls++ + f.last = obj.Key + return "https://cdn.example.com/" + obj.Key, nil +} + +func (f *fakeBlob) Delete(_ context.Context, _ string) error { return nil } + +func TestProfilePageAndState(t *testing.T) { + srv, mem := newTestServer(t, Config{}) + h := srv.Handler() + name := uniq("alice") + cookies := registerUser(t, h, name, "hunter22") + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/profile", nil) + for _, c := range cookies { + req.AddCookie(c) + } + h.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("profile %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Your questions") { + t.Fatal("expected user questions label") + } + if !strings.Contains(rec.Body.String(), "local plumbing codes") { + t.Fatal("missing state helper copy") + } + + csrf := csrfFrom(rec.Body.String()) + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + _ = w.WriteField("_csrf", csrf) + _ = w.WriteField("state", "CA") + _ = w.Close() + req = httptest.NewRequest(http.MethodPost, "/profile", &buf) + req.Header.Set("Content-Type", w.FormDataContentType()) + for _, c := range cookies { + req.AddCookie(c) + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Fatalf("save profile %d %s", rec.Code, rec.Body.String()) + } + u, err := mem.UserByUsername(context.Background(), name) + if err != nil || u.State != "CA" { + t.Fatalf("state not saved: %+v %v", u, err) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/profile", nil) + for _, c := range cookies { + req.AddCookie(c) + } + h.ServeHTTP(rec, req) + csrf = csrfFrom(rec.Body.String()) + buf.Reset() + w = multipart.NewWriter(&buf) + _ = w.WriteField("_csrf", csrf) + _ = w.WriteField("state", "ZZ") + _ = w.Close() + req = httptest.NewRequest(http.MethodPost, "/profile", &buf) + req.Header.Set("Content-Type", w.FormDataContentType()) + for _, c := range cookies { + req.AddCookie(c) + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "valid US state") { + t.Fatalf("expected invalid state error, got %d %s", rec.Code, rec.Body.String()) + } +} + +func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) { + fb := &fakeBlob{} + srv, mem := newTestServer(t, Config{Blob: fb}) + h := srv.Handler() + hubName := uniq("hub") + aliceName := uniq("alice") + hub := seedUser(t, mem, hubName, "hunter22", store.RoleAdmin) + alice := seedUser(t, mem, aliceName, "hunter22", store.RoleUser) + adminCookies := loginUser(t, h, hubName, "hunter22") + + q := &store.RankedQuestion{ + AuthorID: alice.ID, + Title: "Drip", + Body: "Under sink", + City: "Oakland", + } + if err := mem.CreateQuestion(context.Background(), q); err != nil { + t.Fatal(err) + } + ans := &store.Answer{ + QuestionID: q.ID, + AuthorID: hub.ID, + Body: "Replace the cartridge.", + } + if err := mem.UpsertAnswer(context.Background(), ans); err != nil { + t.Fatal(err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/profile", nil) + for _, c := range adminCookies { + req.AddCookie(c) + } + h.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("admin profile %d", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "Questions you answered") || !strings.Contains(body, "Drip") { + t.Fatalf("admin answered list missing: %s", body) + } + + csrf := csrfFrom(body) + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + _ = w.WriteField("_csrf", csrf) + _ = w.WriteField("state", "OR") + part, err := w.CreateFormFile("avatar", "pic.png") + if err != nil { + t.Fatal(err) + } + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) + if err := png.Encode(part, img); err != nil { + t.Fatal(err) + } + _ = w.Close() + req = httptest.NewRequest(http.MethodPost, "/profile", &buf) + req.Header.Set("Content-Type", w.FormDataContentType()) + for _, c := range adminCookies { + req.AddCookie(c) + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Fatalf("avatar upload %d %s", rec.Code, rec.Body.String()) + } + if fb.calls != 1 { + t.Fatalf("expected 1 upload, got %d", fb.calls) + } + hub, _ = mem.UserByUsername(context.Background(), hubName) + if !strings.Contains(hub.AvatarURL, "cdn.example.com/avatars/") { + t.Fatalf("avatar url %q", hub.AvatarURL) + } +} + +func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) { + srv, mem := newTestServer(t, Config{}) + h := srv.Handler() + adminName := uniq("admin") + userName := uniq("user") + admin := seedUser(t, mem, adminName, "hunter22", store.RoleAdmin) + user := seedUser(t, mem, userName, "hunter22", store.RoleUser) + adminCookies := loginUser(t, h, adminName, "hunter22") + userCookies := loginUser(t, h, userName, "hunter22") + + q := &store.RankedQuestion{ + AuthorID: user.ID, + Title: "Pipe noise", + Body: "Clanking", + City: "SF", + HuntDate: pacific.Today(), + } + if err := mem.CreateQuestion(context.Background(), q); err != nil { + t.Fatal(err) + } + + // Missing CSRF + form := strings.NewReader("value=1&view=question") + req := httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/vote", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for _, c := range userCookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("missing csrf want 403, got %d", rec.Code) + } + + // Anonymous HTMX vote → sign-in prompt + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/login", nil) + h.ServeHTTP(rec, req) + anon := rec.Result().Cookies() + csrf := csrfFrom(rec.Body.String()) + form = strings.NewReader("_csrf=" + csrf + "&value=1&view=question") + req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/vote", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("HX-Request", "true") + for _, c := range anon { + req.AddCookie(c) + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Sign in") { + t.Fatalf("anon htmx vote: %d %s", rec.Code, rec.Body.String()) + } + + // User vote + HTMX fragment + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil) + for _, c := range userCookies { + req.AddCookie(c) + } + h.ServeHTTP(rec, req) + csrf = csrfFrom(rec.Body.String()) + form = strings.NewReader("_csrf=" + csrf + "&value=1&view=question") + req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/vote", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("HX-Request", "true") + for _, c := range userCookies { + req.AddCookie(c) + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("vote htmx %d %s", rec.Code, rec.Body.String()) + } + got, err := mem.GetQuestion(context.Background(), q.ID, user.ID) + if err != nil || got.UserVote != 1 || got.Score != 1 { + t.Fatalf("vote not applied: %+v %v", got, err) + } + + // Non-admin answer rejected + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil) + for _, c := range userCookies { + req.AddCookie(c) + } + h.ServeHTTP(rec, req) + csrf = csrfFrom(rec.Body.String()) + form = strings.NewReader("_csrf=" + csrf + "&body=Nope") + req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/answer", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for _, c := range userCookies { + req.AddCookie(c) + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("non-admin answer want 403, got %d", rec.Code) + } + + // Admin answer success (HTMX) + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil) + for _, c := range adminCookies { + req.AddCookie(c) + } + h.ServeHTTP(rec, req) + csrf = csrfFrom(rec.Body.String()) + form = strings.NewReader("_csrf=" + csrf + "&body=Tighten+the+nuts.") + req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/answer", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("HX-Request", "true") + for _, c := range adminCookies { + req.AddCookie(c) + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Tighten the nuts") { + t.Fatalf("admin answer: %d %s", rec.Code, rec.Body.String()) + } + if _, err := mem.GetAnswer(context.Background(), q.ID); err != nil { + t.Fatal(err) + } + + // Hide invalid id + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil) + for _, c := range adminCookies { + req.AddCookie(c) + } + h.ServeHTTP(rec, req) + csrf = csrfFrom(rec.Body.String()) + form = strings.NewReader("_csrf=" + csrf) + req = httptest.NewRequest(http.MethodPost, "/questions/does-not-exist/hide", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for _, c := range adminCookies { + req.AddCookie(c) + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("hide missing want 404, got %d", rec.Code) + } + + // Admin hide success + form = strings.NewReader("_csrf=" + csrf) + req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/hide", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for _, c := range adminCookies { + req.AddCookie(c) + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Fatalf("hide %d %s", rec.Code, rec.Body.String()) + } + hidden, err := mem.GetQuestion(context.Background(), q.ID, admin.ID) + if err != nil || !hidden.Hidden { + t.Fatalf("question not hidden: %+v %v", hidden, err) + } +} + +func mergeCookies(sets ...[]*http.Cookie) []*http.Cookie { + byName := map[string]*http.Cookie{} + for _, set := range sets { + for _, c := range set { + byName[c.Name] = c + } + } + out := make([]*http.Cookie, 0, len(byName)) + for _, c := range byName { + out = append(out, c) + } + return out +} + +func csrfFrom(html string) string { + const needle = `name="_csrf" value="` + i := strings.Index(html, needle) + if i < 0 { + return "" + } + html = html[i+len(needle):] + j := strings.Index(html, `"`) + if j < 0 { + return "" + } + return html[:j] +} + +// TestRegisterThrottleUsesTCPPeerThroughRouter ensures forged X-Forwarded-For +// cannot bypass rate limits when the direct peer is outside TrustedProxies. +// This must go through Handler() so middleware ordering bugs are caught. +func TestRegisterThrottleUsesTCPPeerThroughRouter(t *testing.T) { + _, proxyNet, err := net.ParseCIDR("10.0.0.0/8") + if err != nil { + t.Fatal(err) + } + srv, _ := newTestServer(t, Config{TrustedProxies: []*net.IPNet{proxyNet}}) + // Tight window so the test stays fast. + srv.registerIP = newThrottle(3, time.Minute, 100) + h := srv.Handler() + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/register", nil)) + cookies := rec.Result().Cookies() + csrf := csrfFrom(rec.Body.String()) + if csrf == "" { + t.Fatal("missing csrf") + } + + post := func(xff string) int { + form := strings.NewReader("_csrf=" + csrf + "&username=ab&password=hunter22") + req := httptest.NewRequest(http.MethodPost, "/register", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.RemoteAddr = "203.0.113.50:9" + req.Header.Set("X-Forwarded-For", xff) + for _, c := range cookies { + req.AddCookie(c) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + return w.Code + } + + if code := post("198.51.100.1"); code != http.StatusOK { + t.Fatalf("attempt 1: got %d want 200 (validation error page)", code) + } + if code := post("198.51.100.2"); code != http.StatusOK { + t.Fatalf("attempt 2: got %d want 200", code) + } + if code := post("198.51.100.3"); code != http.StatusOK { + t.Fatalf("attempt 3: got %d want 200", code) + } + if code := post("198.51.100.4"); code != http.StatusTooManyRequests { + t.Fatalf("forged XFF must not bypass peer throttle, got %d want 429", code) + } +} diff --git a/internal/web/throttle.go b/internal/web/throttle.go new file mode 100644 index 0000000..846e075 --- /dev/null +++ b/internal/web/throttle.go @@ -0,0 +1,309 @@ +package web + +import ( + "container/list" + "log" + "net" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" +) + +const defaultThrottleMaxKeys = 10_000 + +// throttle is a sliding-window rate limiter with LRU eviction at capacity. +type throttle struct { + mu sync.Mutex + hits map[string]*throttleEntry + lru *list.List // front = most recently used + limit int + window time.Duration + maxKeys int + rejects atomic.Uint64 + lastLog time.Time +} + +type throttleEntry struct { + times []time.Time + el *list.Element +} + +func newThrottle(limit int, window time.Duration, maxKeys int) *throttle { + if maxKeys <= 0 { + maxKeys = defaultThrottleMaxKeys + } + return &throttle{ + hits: map[string]*throttleEntry{}, + lru: list.New(), + limit: limit, + window: window, + maxKeys: maxKeys, + } +} + +func (t *throttle) allow(key string) bool { + if t == nil || key == "" { + return true + } + t.mu.Lock() + defer t.mu.Unlock() + now := time.Now() + cutoff := now.Add(-t.window) + + ent, ok := t.hits[key] + if ok { + ent.times = pruneTimes(ent.times, cutoff) + if len(ent.times) == 0 { + t.removeLocked(key, ent) + ok = false + } + } + if ok { + if len(ent.times) >= t.limit { + t.touchLocked(key, ent) + return false + } + ent.times = append(ent.times, now) + t.touchLocked(key, ent) + return true + } + + // New key: make room via LRU if needed. + for len(t.hits) >= t.maxKeys { + oldest := t.lru.Back() + if oldest == nil { + break + } + oldKey := oldest.Value.(string) + t.removeLocked(oldKey, t.hits[oldKey]) + n := t.rejects.Add(1) + if time.Since(t.lastLog) > time.Minute { + log.Printf("throttle: LRU evicted key at capacity=%d rejects=%d", t.maxKeys, n) + t.lastLog = now + } + } + ent = &throttleEntry{times: []time.Time{now}} + ent.el = t.lru.PushFront(key) + t.hits[key] = ent + return true +} + +func (t *throttle) touchLocked(key string, ent *throttleEntry) { + if ent.el != nil { + t.lru.MoveToFront(ent.el) + } +} + +func (t *throttle) removeLocked(key string, ent *throttleEntry) { + if ent == nil { + return + } + if ent.el != nil { + t.lru.Remove(ent.el) + } + delete(t.hits, key) +} + +func (t *throttle) lenKeys() int { + t.mu.Lock() + defer t.mu.Unlock() + return len(t.hits) +} + +func pruneTimes(xs []time.Time, cutoff time.Time) []time.Time { + n := 0 + for _, ts := range xs { + if ts.After(cutoff) { + xs[n] = ts + n++ + } + } + return xs[:n] +} + +// failureTracker records auth failures for progressive delay (not a hard lockout). +type failureTracker struct { + mu sync.Mutex + fails map[string]failState + window time.Duration + maxKeys int +} + +type failState struct { + count int + last time.Time +} + +func newFailureTracker(window time.Duration, maxKeys int) *failureTracker { + if maxKeys <= 0 { + maxKeys = defaultThrottleMaxKeys + } + return &failureTracker{ + fails: map[string]failState{}, + window: window, + maxKeys: maxKeys, + } +} + +func (f *failureTracker) delay(key string) time.Duration { + if f == nil || key == "" { + return 0 + } + f.mu.Lock() + defer f.mu.Unlock() + now := time.Now() + f.evictExpiredLocked(now) + st, ok := f.fails[key] + if !ok { + return 0 + } + return progressiveDelay(st.count) +} + +func (f *failureTracker) record(key string) { + if f == nil || key == "" { + return + } + f.mu.Lock() + defer f.mu.Unlock() + now := time.Now() + f.evictExpiredLocked(now) + st := f.fails[key] + if st.count == 0 && len(f.fails) >= f.maxKeys { + // Drop an arbitrary expired-or-oldest entry. + for k, v := range f.fails { + if now.Sub(v.last) > f.window/2 { + delete(f.fails, k) + break + } + } + if len(f.fails) >= f.maxKeys { + return + } + } + st.count++ + st.last = now + f.fails[key] = st +} + +func (f *failureTracker) clear(key string) { + if f == nil || key == "" { + return + } + f.mu.Lock() + defer f.mu.Unlock() + delete(f.fails, key) +} + +func (f *failureTracker) lenKeys() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.fails) +} + +func (f *failureTracker) evictExpiredLocked(now time.Time) { + cutoff := now.Add(-f.window) + for k, st := range f.fails { + if st.last.Before(cutoff) { + delete(f.fails, k) + } + } +} + +func progressiveDelay(failCount int) time.Duration { + switch { + case failCount <= 1: + return 0 + case failCount == 2: + return 200 * time.Millisecond + case failCount == 3: + return 500 * time.Millisecond + case failCount == 4: + return time.Second + default: + return 2 * time.Second + } +} + +func (s *Server) clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + peer := net.ParseIP(host) + if peer == nil || !ipInNets(peer, s.cfg.TrustedProxies) { + return host + } + xff := r.Header.Get("X-Forwarded-For") + if xff == "" { + return host + } + parts := strings.Split(xff, ",") + // Walk right-to-left; skip trusted hops; first untrusted is the client. + for i := len(parts) - 1; i >= 0; i-- { + p := net.ParseIP(strings.TrimSpace(parts[i])) + if p == nil { + continue + } + if !ipInNets(p, s.cfg.TrustedProxies) { + return p.String() + } + } + return host +} + +func ipInNets(ip net.IP, nets []*net.IPNet) bool { + for _, n := range nets { + if n.Contains(ip) { + return true + } + } + return false +} + +func parseCIDRs(raw string) []*net.IPNet { + var out []*net.IPNet + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + _, n, err := net.ParseCIDR(part) + if err != nil { + log.Printf("trusted proxy CIDR ignored %q: %v", part, err) + continue + } + out = append(out, n) + } + return out +} + +func authTooMany(w http.ResponseWriter) { + http.Error(w, "too many attempts; try again later", http.StatusTooManyRequests) +} + +func loginFailKey(ip, usernameKey string) string { + return ip + "\x00" + usernameKey +} + +func (s *Server) allowLoginAttempt(w http.ResponseWriter, r *http.Request, usernameKey string) bool { + ip := s.clientIP(r) + if !s.loginIP.allow(ip) { + authTooMany(w) + return false + } + if d := s.loginFail.delay(loginFailKey(ip, usernameKey)); d > 0 { + time.Sleep(d) + } + return true +} + +func (s *Server) allowRegisterAttempt(w http.ResponseWriter, r *http.Request) bool { + if !s.registerIP.allow(s.clientIP(r)) { + authTooMany(w) + return false + } + return true +} diff --git a/internal/web/throttle_test.go b/internal/web/throttle_test.go new file mode 100644 index 0000000..156c0f3 --- /dev/null +++ b/internal/web/throttle_test.go @@ -0,0 +1,127 @@ +package web + +import ( + "net" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" +) + +func TestThrottleWindowAndEviction(t *testing.T) { + th := newThrottle(2, 50*time.Millisecond, 100) + if !th.allow("a") || !th.allow("a") { + t.Fatal("first two should pass") + } + if th.allow("a") { + t.Fatal("third within window should fail") + } + time.Sleep(60 * time.Millisecond) + if !th.allow("a") { + t.Fatal("after window should pass") + } + // Expired empty keys should be removed on next allow of another key path. + time.Sleep(60 * time.Millisecond) + _ = th.allow("b") + if th.lenKeys() > 2 { + t.Fatalf("expected eviction of stale keys, got %d", th.lenKeys()) + } +} + +func TestThrottleMaxKeys(t *testing.T) { + th := newThrottle(5, time.Minute, 2) + if !th.allow("one") || !th.allow("two") { + t.Fatal("first keys should pass") + } + if !th.allow("three") { + t.Fatal("over maxKeys should LRU-evict and accept new key") + } + if th.lenKeys() != 2 { + t.Fatalf("keys=%d want 2", th.lenKeys()) + } +} + +func TestThrottleConcurrent(t *testing.T) { + th := newThrottle(50, time.Minute, 1000) + var wg sync.WaitGroup + var okCount int + var mu sync.Mutex + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if th.allow("same") { + mu.Lock() + okCount++ + mu.Unlock() + } + }() + } + wg.Wait() + if okCount != 50 { + t.Fatalf("ok=%d want 50", okCount) + } +} + +func TestFailureTrackerProgressiveAndClear(t *testing.T) { + f := newFailureTracker(time.Minute, 100) + if d := f.delay("k"); d != 0 { + t.Fatalf("fresh delay=%v", d) + } + f.record("k") + f.record("k") + if d := f.delay("k"); d != 200*time.Millisecond { + t.Fatalf("delay after 2 fails=%v", d) + } + f.clear("k") + if d := f.delay("k"); d != 0 { + t.Fatalf("after clear delay=%v", d) + } +} + +func TestFailureTrackerEvictsExpired(t *testing.T) { + f := newFailureTracker(30*time.Millisecond, 100) + f.record("old") + time.Sleep(40 * time.Millisecond) + _ = f.delay("other") // triggers eviction + if f.lenKeys() != 0 { + t.Fatalf("expired key remained, keys=%d", f.lenKeys()) + } +} + +func TestClientIPTrustProxy(t *testing.T) { + _, proxyNet, err := net.ParseCIDR("10.0.0.0/8") + if err != nil { + t.Fatal(err) + } + srv := &Server{cfg: Config{TrustedProxies: []*net.IPNet{proxyNet}}} + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.1:1234" + req.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.1") + if got := srv.clientIP(req); got != "203.0.113.9" { + t.Fatalf("trusted xff got %q", got) + } + + req.RemoteAddr = "203.0.113.50:9" + if got := srv.clientIP(req); got != "203.0.113.50" { + t.Fatalf("untrusted peer should ignore xff, got %q", got) + } +} + +func TestNoGlobalUsernameHardLockout(t *testing.T) { + // Victim IP should still be allowed after another IP burns attempts for the same username. + srv := &Server{ + loginIP: newThrottle(20, time.Minute, 100), + loginFail: newFailureTracker(time.Minute, 100), + } + for i := 0; i < 20; i++ { + srv.loginFail.record(loginFailKey("1.1.1.1", "alice")) + } + victim := httptest.NewRequest(http.MethodPost, "/login", nil) + victim.RemoteAddr = "2.2.2.2:9" + w := httptest.NewRecorder() + if !srv.allowLoginAttempt(w, victim, "alice") { + t.Fatal("victim IP must not be hard-locked by username-only attempts") + } +} diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..67136d6 --- /dev/null +++ b/schema.sql @@ -0,0 +1,45 @@ +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')), + avatar_url TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS questions ( + id TEXT PRIMARY KEY, + author_id TEXT NOT NULL REFERENCES users(id), + title TEXT NOT NULL, + body TEXT NOT NULL, + city TEXT NOT NULL DEFAULT '', + hunt_date TEXT NOT NULL, + hidden INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_questions_hunt_date ON questions(hunt_date, hidden); + +CREATE TABLE IF NOT EXISTS votes ( + user_id TEXT NOT NULL REFERENCES users(id), + question_id TEXT NOT NULL REFERENCES questions(id) ON DELETE CASCADE, + value INTEGER NOT NULL CHECK (value IN (-1, 1)), + PRIMARY KEY (user_id, question_id) +); + +CREATE TABLE IF NOT EXISTS answers ( + question_id TEXT PRIMARY KEY REFERENCES questions(id) ON DELETE CASCADE, + author_id TEXT NOT NULL REFERENCES users(id), + body TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + data BYTEA NOT NULL, + expiry TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS sessions_expiry_idx ON sessions (expiry); diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..0f71770 --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,12 @@ +version: "2" +sql: + - engine: "postgresql" + schema: "schema.sql" + queries: "db/queries" + gen: + go: + package: "sqlc" + out: "internal/store/sqlc" + sql_package: "database/sql" + emit_json_tags: false + emit_empty_slices: true diff --git a/static/app.css b/static/app.css new file mode 100644 index 0000000..7fec899 --- /dev/null +++ b/static/app.css @@ -0,0 +1,789 @@ +:root { + --bg: #161719; + --panel: #1e2023; + --ink: #ecebe7; + --muted: #8d9096; + --line: #2e3136; + --zinc: #6b7078; + --signal: #e96a26; + --signal-ink: #161719; + --err: #e24b4b; + --err-bg: #2a1a1a; + --ok: #e96a26; + --sans: "Archivo", "Helvetica Neue", sans-serif; + --mono: "IBM Plex Mono", ui-monospace, monospace; + --pad: env(safe-area-inset-bottom, 0px); +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + min-height: 100%; + background: var(--bg); + color: var(--ink); + font: 16px/1.45 var(--sans); + -webkit-text-size-adjust: 100%; + color-scheme: dark; +} + +body { + background-image: + linear-gradient(180deg, #1a1c1f 0%, var(--bg) 180px), + repeating-linear-gradient( + 90deg, + transparent, + transparent 47px, + #1c1e21 47px, + #1c1e21 48px + ); + background-size: 100% 100%, 48px 48px; +} + +img, svg { display: block; } + +a { + color: var(--signal); + text-underline-offset: 0.18em; +} + +.skip { + position: absolute; + left: -999px; +} +.skip:focus { + left: 12px; + top: 12px; + z-index: 20; + background: var(--panel); + padding: 8px 12px; +} + +.top { + position: sticky; + top: 0; + z-index: 20; + background: #141516; + border-bottom: 1px solid var(--line); + box-shadow: inset 0 2px 0 var(--signal); +} + +.top-inner { + width: min(840px, calc(100% - 32px)); + margin: 0 auto; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px 16px; + padding: calc(12px + env(safe-area-inset-top, 0px)) 0 12px; +} + +.logo { + display: flex; + align-items: center; + gap: 12px; + color: var(--signal); + text-decoration: none; +} + +.mark { + width: 32px; + height: 32px; + flex: none; +} + +.logo-text { + display: flex; + flex-direction: column; + gap: 3px; +} + +.logo-name { + font-size: 0.95rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ink); + line-height: 1.15; +} + +.tagline { + color: var(--muted); + font-family: var(--mono); + font-size: 0.65rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.top-nav { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.who { + color: var(--muted); + font-family: var(--mono); + font-size: 0.75rem; + max-width: 9rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + letter-spacing: 0.04em; +} + +.account-menu { + position: relative; +} + +.account-menu-toggle { + gap: 8px; + list-style: none; + padding-left: 10px; + border-color: transparent; +} + +.account-menu-toggle:hover { + border-color: transparent; +} + +.account-menu-toggle::-webkit-details-marker { display: none; } +.account-menu-toggle::marker { content: ""; } + +.nav-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + object-fit: cover; + flex: none; + border: 1px solid var(--line); + background: var(--panel); +} + +.nav-avatar-ghost { + display: inline-flex; + align-items: center; + justify-content: center; + font-family: var(--mono); + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0; + text-transform: uppercase; + color: var(--signal); +} + +.account-menu-caret { + width: 0; + height: 0; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid var(--muted); +} + +.account-menu[open] .account-menu-caret { + border-top: none; + border-bottom: 5px solid var(--muted); +} + +.account-menu-panel { + position: absolute; + right: 0; + top: calc(100% + 6px); + z-index: 10; + min-width: 10.5rem; + padding: 6px; + background: var(--panel); + border: 1px solid var(--line); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); +} + +.account-menu-item { + display: flex; + align-items: center; + width: 100%; + min-height: 40px; + padding: 0 12px; + border: 0; + border-radius: 2px; + background: transparent; + color: var(--ink); + font: inherit; + font-weight: 600; + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + text-decoration: none; + text-align: left; + cursor: pointer; +} + +.account-menu-item:hover, +.account-menu-item:focus-visible { + background: #26282c; + outline: none; +} + +.logout { margin: 0; } + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + padding: 0 16px; + border-radius: 3px; + border: 1px solid transparent; + font: inherit; + font-weight: 600; + font-size: 0.8rem; + letter-spacing: 0.08em; + text-transform: uppercase; + text-decoration: none; + cursor: pointer; + touch-action: manipulation; +} + +.btn-primary { + background: var(--signal); + color: var(--signal-ink); +} + +.btn-primary:hover { filter: brightness(1.08); } + +.btn-ghost { + background: transparent; + color: var(--ink); + border-color: var(--line); +} + +.btn-ghost:hover { border-color: var(--zinc); } + +.wrap { + width: min(840px, calc(100% - 32px)); + margin: 28px auto 56px; +} + +#flash:empty { display: none; } + +.banner { + width: min(840px, calc(100% - 32px)); + margin: 16px auto 0; + padding: 12px 16px; + background: var(--panel); + border: 1px solid var(--line); + border-left: 3px solid var(--signal); + color: var(--ink); +} + +.banner.error { + background: var(--err-bg); + border-left-color: var(--err); + color: #ffd0d0; +} + +.hunt-head { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: end; + gap: 20px; + margin-bottom: 8px; + padding-bottom: 20px; + border-bottom: 1px solid var(--line); +} + +.eyebrow { + margin: 0; + font-family: var(--mono); + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 0.68rem; + font-weight: 500; + color: var(--signal); +} + +h1 { + margin: 10px 0 0; + font-size: clamp(1.8rem, 4.5vw, 2.75rem); + font-weight: 500; + letter-spacing: -0.03em; + line-height: 1.05; + text-wrap: pretty; +} + +.date-nav { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.chip { + min-height: 40px; + padding: 8px 12px; + border-radius: 3px; + border: 1px solid var(--line); + background: transparent; + color: var(--ink); + text-decoration: none; + font-family: var(--mono); + font-weight: 500; + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.chip.is-on { + background: var(--signal); + border-color: var(--signal); + color: var(--signal-ink); +} + +.date-form { + display: flex; + gap: 6px; + align-items: center; +} + +.date-form input[type="date"] { + min-height: 44px; + padding: 0 10px; + border: 1px solid var(--line); + border-radius: 3px; + font: inherit; + font-family: var(--mono); + font-size: 0.8rem; + background: var(--panel); + color: var(--ink); + color-scheme: dark; +} + +.board { + list-style: none; + margin: 0; + padding: 0; + background: var(--panel); + border: 1px solid var(--line); +} + +.row { + display: grid; + grid-template-columns: 2.8rem 3.25rem 1fr; + gap: 4px 8px; + align-items: start; + padding: 14px 12px; + border-bottom: 1px solid var(--line); +} + +.row:last-child { border-bottom: 0; } + +.row:hover { background: #24262b; } + +.rank { + font-family: var(--mono); + font-weight: 500; + font-size: 1.15rem; + line-height: 1; + color: var(--zinc); + text-align: right; + padding-top: 12px; +} + +.row:first-child .rank { color: var(--signal); } + +.vote { + display: flex; + flex-direction: column; + align-items: center; + gap: 0; +} + +.vote form { margin: 0; } + +.vote-btn { + width: 44px; + height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + background: transparent; + color: var(--zinc); + cursor: pointer; + text-decoration: none; + touch-action: manipulation; + border-radius: 3px; +} + +.vote-btn:hover { color: var(--ink); background: #2a2d32; } + +.vote-btn.is-up { color: var(--signal); } +.vote-btn.is-down { color: var(--muted); } + +.score { + font-family: var(--mono); + font-variant-numeric: tabular-nums; + font-weight: 500; + font-size: 0.85rem; +} + +.q-title { + color: var(--ink); + font-weight: 500; + text-decoration: none; + font-size: 1.05rem; + text-wrap: pretty; +} + +.q-title:hover { color: var(--signal); } + +.meta { + margin: 6px 0 0; + color: var(--muted); + font-family: var(--mono); + font-size: 0.72rem; + letter-spacing: 0.02em; +} + +.dot { margin: 0 0.35em; } + +.badge { + display: inline-block; + margin-left: 8px; + padding: 2px 6px; + border: 1px solid var(--signal); + color: var(--signal); + font-size: 0.62rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.empty { + padding: 48px 16px; + text-align: left; + color: var(--muted); +} + +.empty-kicker { + font-family: var(--mono); + font-size: 0.72rem; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--signal); + margin: 0 0 8px; +} + +.inline-hide { margin: 6px 0 0; } + +.linkish { + background: none; + border: 0; + padding: 0; + color: var(--muted); + font: inherit; + font-family: var(--mono); + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; + text-decoration: underline; + cursor: pointer; + min-height: 44px; +} + +.lede, .hint, .switch { + color: var(--muted); +} + +.lede { margin: 8px 0 0; max-width: 38rem; text-wrap: pretty; } + +.ask, .answer-form { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 20px; +} + +label { + font-family: var(--mono); + font-weight: 500; + font-size: 0.68rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted); +} + +input[type="text"], +input[type="password"], +textarea { + width: 100%; + font: inherit; + font-size: 16px; + padding: 12px 12px; + border: 1px solid var(--line); + border-radius: 3px; + background: #141516; + color: var(--ink); +} + +input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:focus-visible { + outline: 2px solid var(--signal); + outline-offset: 2px; +} + +.optional { font-weight: 400; letter-spacing: 0.04em; } + +.panel-wrap, .auth-wrap { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 3px; + padding: 24px 20px 28px; +} + +.question-page .q-detail { + display: grid; + grid-template-columns: 3.25rem 1fr; + gap: 8px 14px; + background: var(--panel); + border: 1px solid var(--line); + padding: 18px 14px; +} + +.question-page h1 { + font-size: clamp(1.5rem, 3.5vw, 2.1rem); + line-height: 1.15; +} + +.q-body, .answer-body { + white-space: pre-wrap; + margin: 14px 0 0; + text-wrap: pretty; +} + +.crumb { margin: 0 0 20px; } +.crumb a { + color: var(--muted); + text-decoration: none; + font-family: var(--mono); + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.crumb a:hover { color: var(--signal); } + +.answer { + margin-top: 16px; + padding: 20px 18px; + background: var(--panel); + border: 1px solid var(--line); +} + +.answer.is-in { + border-color: var(--signal); +} + +.answer-kicker { + margin: 0 0 6px; + font-family: var(--mono); + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 0.68rem; + font-weight: 500; + color: var(--signal); +} + +.answer h2 { + margin: 0; + font-size: 1.15rem; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.byline { + margin: 6px 0 0; + color: var(--muted); + font-family: var(--mono); + font-size: 0.72rem; +} + +.waiting { color: var(--muted); margin: 0; font-family: var(--mono); font-size: 0.8rem; } + +.auth-wrap { + width: min(420px, calc(100% - 32px)); +} + +.auth-wrap h1 { font-size: 1.7rem; } + +.site-footer { + width: min(840px, calc(100% - 32px)); + margin: 0 auto; + padding: 20px 0 calc(36px + var(--pad)); + color: var(--muted); + font-family: var(--mono); + font-size: 0.72rem; + line-height: 1.55; + border-top: 1px solid var(--line); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +@media (min-width: 640px) { + .row { + grid-template-columns: 3.4rem 3.5rem 1fr; + padding: 16px 18px; + } + .rank { font-size: 1.35rem; } + .panel-wrap { padding: 32px; } +} + +@media (prefers-reduced-motion: reduce) { + .btn-primary:hover { filter: none; } +} + +.admin-users { margin-top: 20px; overflow-x: auto; } + +.user-table { + width: 100%; + border-collapse: collapse; + font-size: 0.95rem; +} + +.user-table th, +.user-table td { + text-align: left; + padding: 12px 10px; + border-bottom: 1px solid var(--line); + vertical-align: middle; +} + +.user-table th { + color: var(--muted); + font-family: var(--mono); + font-size: 0.7rem; + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.user-table .mono { + font-family: var(--mono); + font-size: 0.85rem; +} + +.user-actions form { margin: 0; display: inline; } + +.user-actions .btn { + min-height: 36px; + padding: 0 12px; + font-size: 0.7rem; +} + +.profile-page h2 { + margin: 40px 0 16px; + font-size: 1.15rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.profile-form { + display: flex; + flex-direction: column; + gap: 10px; + max-width: 28rem; + margin-top: 24px; +} + +.profile-avatar { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 8px; +} + +.avatar { + width: 96px; + height: 96px; + border-radius: 50%; + object-fit: cover; + border: 1px solid var(--line); + background: var(--panel); +} + +.avatar-empty { + display: flex; + align-items: center; + justify-content: center; + font-family: var(--mono); + font-size: 1.6rem; + font-weight: 600; + color: var(--signal); + text-transform: uppercase; +} + +.avatar-fields label { margin-bottom: 6px; display: block; } + +.hint, .muted { + margin: 0; + color: var(--muted); + font-family: var(--mono); + font-size: 0.72rem; + line-height: 1.45; +} + +.profile-form select, +.profile-form input[type="file"] { + width: 100%; + min-height: 44px; + padding: 8px 12px; + border: 1px solid var(--line); + border-radius: 3px; + background: var(--panel); + color: var(--ink); + font: inherit; +} + +.profile-form .btn { align-self: flex-start; margin-top: 8px; } + +.profile-q-list { + list-style: none; + margin: 0; + padding: 0; + border-top: 1px solid var(--line); +} + +.profile-q-list li { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 8px 16px; + padding: 14px 0; + border-bottom: 1px solid var(--line); +} + +.profile-q-list a { + color: var(--ink); + text-decoration: none; + font-weight: 500; +} + +.profile-q-list a:hover { color: var(--signal); } + +.profile-q-list .meta { + color: var(--muted); + font-family: var(--mono); + font-size: 0.72rem; +} diff --git a/static/htmx.min.js b/static/htmx.min.js new file mode 100644 index 0000000..59937d7 --- /dev/null +++ b/static/htmx.min.js @@ -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(/
]*)?>[\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(''+t+"");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.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;eAdmin
+| Username | +Role | +Actions | +
|---|---|---|
| {{.Username}} | +{{.Role}} | ++ {{if eq .Role "admin"}} + + {{else}} + + {{end}} + | +
| No users yet. | ||
{{if .IsToday}}Live board{{else if .IsYesterday}}Prior board{{else}}Archive{{end}}
+Access
+New here? Create an account
+Shop response
+{{.Answer.Body}}
+ {{else}} +No answer yet. Check back after the hunt.
+ {{end}} +Queue empty
+No questions yet. Be the first to ask.
+ {{else}} +No questions on this day.
+ {{end}} +Account
+Nothing here yet.
+ {{end}} +← {{pacificLabel .Question.HuntDate}}
+{{.Question.Body}}
+ {{if isAdmin .User}} + + {{end}} +New account
+Already have an account? Sign in
+It lands on today’s hunt (Pacific time). People vote; the ranking resets at midnight PT.
+ {{if .Error}}{{end}} + +