Address production-readiness review: clearer errors, safer votes, and ops hardening.

Distinguish auth/lookup failures, make votes idempotent on visible questions, bound shutdown, page admin users, LRU throttle, trusted-proxy CIDRs, avatar cleanup, versioned migrations, and session cleanup logging.
This commit is contained in:
2026-08-22 12:16:59 -07:00
parent 5bdaa8977f
commit 29b0536215
26 changed files with 612 additions and 146 deletions
+3 -2
View File
@@ -11,8 +11,9 @@ DATABASE_URL=postgresql://user:password@host.example.com:5432/postgres?sslmode=v
# When PORT is set (App Platform), cookies are Secure by default; SECURE_COOKIE=0 is rejected. # When PORT is set (App Platform), cookies are Secure by default; SECURE_COOKIE=0 is rejected.
# Locally, set to 1 when serving over HTTPS: # Locally, set to 1 when serving over HTTPS:
SECURE_COOKIE=0 SECURE_COOKIE=0
# Set to 1 only behind a trusted reverse proxy that sets X-Forwarded-For. # Comma-separated CIDRs of reverse proxies allowed to set X-Forwarded-For
# TRUST_PROXY=0 # (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. # DigitalOcean Spaces (profile avatars). Leave unset to disable uploads.
# SPACES_KEY= # SPACES_KEY=
# SPACES_SECRET= # SPACES_SECRET=
+29 -5
View File
@@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"log" "log"
"net"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@@ -56,7 +57,7 @@ func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader
srv, err := web.New(store.NewPostgres(db), sessions.Store(), plumber.TemplateFS, plumber.StaticFS, web.Config{ srv, err := web.New(store.NewPostgres(db), sessions.Store(), plumber.TemplateFS, plumber.StaticFS, web.Config{
AdminSetupSecret: strings.TrimSpace(os.Getenv("ADMIN_SETUP_SECRET")), AdminSetupSecret: strings.TrimSpace(os.Getenv("ADMIN_SETUP_SECRET")),
SecureCookie: secureCookieFromEnv(), SecureCookie: secureCookieFromEnv(),
TrustProxy: os.Getenv("TRUST_PROXY") == "1", TrustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDRS")),
Blob: uploader, Blob: uploader,
}) })
if err != nil { if err != nil {
@@ -65,6 +66,22 @@ func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader
return srv.Handler() 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) // secureCookieFromEnv defaults to secure when PORT is set (PaaS/production)
// and refuses an explicit disable in that environment. // and refuses an explicit disable in that environment.
func secureCookieFromEnv() bool { func secureCookieFromEnv() bool {
@@ -96,12 +113,19 @@ func run(httpSrv *http.Server) {
case sig := <-sigCh: case sig := <-sigCh:
log.Printf("shutdown signal: %v", sig) log.Printf("shutdown signal: %v", sig)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() err := httpSrv.Shutdown(ctx)
if err := httpSrv.Shutdown(ctx); err != nil { cancel()
if err != nil {
log.Printf("shutdown: %v", err) log.Printf("shutdown: %v", err)
_ = httpSrv.Close()
} }
if err := <-errCh; err != nil && !errors.Is(err, http.ErrServerClosed) { select {
log.Fatal(err) 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")
} }
} }
} }
+1 -1
View File
@@ -13,6 +13,6 @@ SET data = excluded.data, expiry = excluded.expiry;
DELETE FROM sessions DELETE FROM sessions
WHERE token = $1; WHERE token = $1;
-- name: DeleteExpiredSessions :exec -- name: DeleteExpiredSessions :execrows
DELETE FROM sessions DELETE FROM sessions
WHERE expiry <= now(); WHERE expiry <= now();
+11 -1
View File
@@ -15,7 +15,17 @@ WHERE username = $1;
-- name: ListUsers :many -- name: ListUsers :many
SELECT id, username, name, role, avatar_url, state, created_at SELECT id, username, name, role, avatar_url, state, created_at
FROM users FROM users
ORDER BY created_at ASC 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); LIMIT sqlc.arg(row_limit);
-- name: CountAdmins :one -- name: CountAdmins :one
+13 -3
View File
@@ -3,12 +3,22 @@ SELECT value
FROM votes FROM votes
WHERE user_id = $1 AND question_id = $2; 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 -- name: DeleteVote :exec
DELETE FROM votes DELETE FROM votes
WHERE user_id = $1 AND question_id = $2; WHERE user_id = $1 AND question_id = $2;
-- name: UpsertVote :exec -- name: UpsertVoteOnVisible :execrows
INSERT INTO votes (user_id, question_id, value) INSERT INTO votes (user_id, question_id, value)
VALUES ($1, $2, $3) SELECT $1, $2, $3
FROM questions q
WHERE q.id = $2 AND q.hidden = 0
ON CONFLICT (user_id, question_id) DO UPDATE ON CONFLICT (user_id, question_id) DO UPDATE
SET value = excluded.value; SET value = excluded.value
WHERE EXISTS (
SELECT 1 FROM questions q2 WHERE q2.id = excluded.question_id AND q2.hidden = 0
);
+41 -4
View File
@@ -17,6 +17,7 @@ import (
type Uploader interface { type Uploader interface {
Enabled() bool Enabled() bool
Upload(ctx context.Context, obj FileUpload) (publicURL string, err error) 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). // FileUpload is a file body to store (e.g. an avatar).
@@ -51,6 +52,8 @@ func (Disabled) Upload(context.Context, FileUpload) (string, error) {
return "", fmt.Errorf("avatar uploads are not configured") 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. // FromEnv builds an Uploader from SPACES_* environment variables.
func FromEnv() Uploader { func FromEnv() Uploader {
return NewSpaces(SpacesConfig{ return NewSpaces(SpacesConfig{
@@ -99,11 +102,45 @@ func (s *spaces) Upload(ctx context.Context, obj FileUpload) (string, error) {
if _, err := s.client.PutObject(ctx, input); err != nil { if _, err := s.client.PutObject(ctx, input); err != nil {
return "", err return "", err
} }
if s.cfg.CDNBase != "" { return s.publicURL(key), nil
return s.cfg.CDNBase + "/" + 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
} }
// Virtual-hostedstyle Spaces URL.
host := strings.TrimPrefix(s.cfg.Endpoint, "https://") host := strings.TrimPrefix(s.cfg.Endpoint, "https://")
host = strings.TrimPrefix(host, "http://") host = strings.TrimPrefix(host, "http://")
return fmt.Sprintf("https://%s.%s/%s", s.cfg.Bucket, host, key), nil 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 ""
} }
+37 -10
View File
@@ -43,7 +43,7 @@ func (m *Memory) CreateUser(_ context.Context, u *User) error {
} }
u.Username = NormalizeUsername(u.Username) u.Username = NormalizeUsername(u.Username)
if _, ok := m.byName[u.Username]; ok { if _, ok := m.byName[u.Username]; ok {
return fmt.Errorf("username taken") return ErrDuplicateUsername
} }
if u.ID == "" { if u.ID == "" {
u.ID = uuid.NewString() u.ID = uuid.NewString()
@@ -94,18 +94,44 @@ func (m *Memory) UserByUsername(_ context.Context, username string) (*User, erro
return &cp, nil return &cp, nil
} }
func (m *Memory) ListUsers(_ context.Context) ([]User, error) { func (m *Memory) ListUsers(_ context.Context, q ListUsersQuery) ([]User, string, string, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() 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)) out := make([]User, 0, len(m.users))
for _, u := range 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) out = append(out, *u)
} }
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt < out[j].CreatedAt }) sort.Slice(out, func(i, j int) bool {
if len(out) > AdminUsersLimit { if out[i].CreatedAt != out[j].CreatedAt {
out = out[:AdminUsersLimit] return out[i].CreatedAt > out[j].CreatedAt
} }
return out, nil 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) { func (m *Memory) CountAdmins(_ context.Context) (int, error) {
@@ -331,16 +357,17 @@ func (m *Memory) UpsertAnswer(_ context.Context, a *Answer) error {
func (m *Memory) Vote(_ context.Context, userID, questionID string, value int) error { func (m *Memory) Vote(_ context.Context, userID, questionID string, value int) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
if value != 1 && value != -1 { if value != 1 && value != -1 && value != 0 {
return fmt.Errorf("invalid vote") return fmt.Errorf("invalid vote")
} }
if _, ok := m.questions[questionID]; !ok { q, ok := m.questions[questionID]
return sql.ErrNoRows if !ok || q.Hidden {
return ErrHiddenOrMissing
} }
if m.votes[questionID] == nil { if m.votes[questionID] == nil {
m.votes[questionID] = map[string]int{} m.votes[questionID] = map[string]int{}
} }
if cur, ok := m.votes[questionID][userID]; ok && cur == value { if value == 0 {
delete(m.votes[questionID], userID) delete(m.votes[questionID], userID)
return nil return nil
} }
+69
View File
@@ -3,6 +3,7 @@ package store
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"log"
) )
// migrateUserProfileColumns adds avatar_url and state when missing (existing DBs). // migrateUserProfileColumns adds avatar_url and state when missing (existing DBs).
@@ -16,3 +17,71 @@ func migrateUserProfileColumns(db *sql.DB) error {
} }
return nil return nil
} }
const migrateLockKey int64 = 0x706c756d5f6d6967 // "plum_mig"
// applyMigrations runs versioned migrations under an advisory lock.
// Fresh databases apply schemaSQL as version 001; later versions are incremental.
func applyMigrations(db *sql.DB, schemaSQL string) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`SELECT pg_advisory_xact_lock($1)`, migrateLockKey); err != nil {
return fmt.Errorf("migrate lock: %w", err)
}
if _, err := tx.Exec(`
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)
}
if err := tx.Commit(); err != nil {
return err
}
applied, err := appliedVersions(db)
if err != nil {
return err
}
migrations := []struct {
version string
run func(*sql.DB) error
}{
{"001_schema", func(db *sql.DB) error { return applySchema(db, 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(db); err != nil {
return fmt.Errorf("migrate %s: %w", m.version, err)
}
if _, err := db.Exec(`INSERT INTO schema_migrations (version) VALUES ($1)`, m.version); err != nil {
return fmt.Errorf("record %s: %w", m.version, err)
}
}
return nil
}
func appliedVersions(db *sql.DB) (map[string]bool, error) {
rows, err := db.Query(`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()
}
+2 -6
View File
@@ -63,13 +63,9 @@ func OpenPostgres(databaseURL, schema string) (*sql.DB, *SessionStore, error) {
_ = db.Close() _ = db.Close()
return nil, nil, fmt.Errorf("postgres ping: %w", err) return nil, nil, fmt.Errorf("postgres ping: %w", err)
} }
if err := applySchema(db, schema); err != nil { if err := applyMigrations(db, schema); err != nil {
_ = db.Close() _ = db.Close()
return nil, nil, fmt.Errorf("apply schema: %w", err) return nil, nil, fmt.Errorf("migrate: %w", err)
}
if err := migrateUserProfileColumns(db); err != nil {
_ = db.Close()
return nil, nil, fmt.Errorf("migrate profile columns: %w", err)
} }
sessions := NewSessionStore(db, 5*time.Minute) sessions := NewSessionStore(db, 5*time.Minute)
return db, sessions, nil return db, sessions, nil
+3 -3
View File
@@ -71,7 +71,7 @@ func (p *Postgres) CreateUser(ctx context.Context, u *User) error {
Role: string(role), Role: string(role),
CreatedAt: u.CreatedAt, CreatedAt: u.CreatedAt,
}); err != nil { }); err != nil {
return err return mapUniqueViolation(err)
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return err return err
@@ -89,8 +89,8 @@ func (p *Postgres) UserByUsername(ctx context.Context, username string) (*User,
return UserByUsername(ctx, p.db, username) return UserByUsername(ctx, p.db, username)
} }
func (p *Postgres) ListUsers(ctx context.Context) ([]User, error) { func (p *Postgres) ListUsers(ctx context.Context, q ListUsersQuery) ([]User, string, string, error) {
return ListUsers(ctx, p.db) return ListUsers(ctx, p.db, q)
} }
func (p *Postgres) CountAdmins(ctx context.Context) (int, error) { func (p *Postgres) CountAdmins(ctx context.Context) (int, error) {
+13 -1
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"log"
"sync" "sync"
"time" "time"
@@ -103,10 +104,21 @@ func (s *SessionStore) cleanupLoop(interval time.Duration) {
defer close(s.stopped) defer close(s.stopped)
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
defer ticker.Stop() defer ticker.Stop()
var lastErrLog time.Time
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
_ = s.q.DeleteExpiredSessions(context.Background()) 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: case <-s.stop:
return return
} }
+7 -4
View File
@@ -10,14 +10,17 @@ import (
"time" "time"
) )
const deleteExpiredSessions = `-- name: DeleteExpiredSessions :exec const deleteExpiredSessions = `-- name: DeleteExpiredSessions :execrows
DELETE FROM sessions DELETE FROM sessions
WHERE expiry <= now() WHERE expiry <= now()
` `
func (q *Queries) DeleteExpiredSessions(ctx context.Context) error { func (q *Queries) DeleteExpiredSessions(ctx context.Context) (int64, error) {
_, err := q.db.ExecContext(ctx, deleteExpiredSessions) result, err := q.db.ExecContext(ctx, deleteExpiredSessions)
return err if err != nil {
return 0, err
}
return result.RowsAffected()
} }
const deleteSession = `-- name: DeleteSession :exec const deleteSession = `-- name: DeleteSession :exec
+26 -4
View File
@@ -129,10 +129,27 @@ func (q *Queries) GetUserRole(ctx context.Context, id string) (string, error) {
const listUsers = `-- name: ListUsers :many const listUsers = `-- name: ListUsers :many
SELECT id, username, name, role, avatar_url, state, created_at SELECT id, username, name, role, avatar_url, state, created_at
FROM users FROM users
ORDER BY created_at ASC WHERE (
LIMIT $1 $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 { type ListUsersRow struct {
ID string ID string
Username string Username string
@@ -143,8 +160,13 @@ type ListUsersRow struct {
CreatedAt string CreatedAt string
} }
func (q *Queries) ListUsers(ctx context.Context, rowLimit int32) ([]ListUsersRow, error) { func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]ListUsersRow, error) {
rows, err := q.db.QueryContext(ctx, listUsers, rowLimit) rows, err := q.db.QueryContext(ctx, listUsers,
arg.Search,
arg.CursorCreated,
arg.CursorID,
arg.RowLimit,
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+30 -9
View File
@@ -42,20 +42,41 @@ func (q *Queries) GetVote(ctx context.Context, arg GetVoteParams) (int32, error)
return value, err return value, err
} }
const upsertVote = `-- name: UpsertVote :exec const questionIsVisible = `-- name: QuestionIsVisible :one
INSERT INTO votes (user_id, question_id, value) SELECT EXISTS(
VALUES ($1, $2, $3) SELECT 1 FROM questions WHERE id = $1 AND hidden = 0
ON CONFLICT (user_id, question_id) DO UPDATE )::bool
SET value = excluded.value
` `
type UpsertVoteParams struct { 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 UserID string
QuestionID string QuestionID string
Value int32 Value int32
} }
func (q *Queries) UpsertVote(ctx context.Context, arg UpsertVoteParams) error { func (q *Queries) UpsertVoteOnVisible(ctx context.Context, arg UpsertVoteOnVisibleParams) (int64, error) {
_, err := q.db.ExecContext(ctx, upsertVote, arg.UserID, arg.QuestionID, arg.Value) result, err := q.db.ExecContext(ctx, upsertVoteOnVisible, arg.UserID, arg.QuestionID, arg.Value)
return err if err != nil {
return 0, err
}
return result.RowsAffected()
} }
+11 -2
View File
@@ -6,15 +6,23 @@ import "context"
const ( const (
HuntListLimit = 100 HuntListLimit = 100
ProfileListLimit = 50 ProfileListLimit = 50
AdminUsersLimit = 200 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. // Store is the application persistence API used by the web layer.
type Store interface { type Store interface {
CreateUser(ctx context.Context, u *User) error CreateUser(ctx context.Context, u *User) error
UserByID(ctx context.Context, id string) (*User, error) UserByID(ctx context.Context, id string) (*User, error)
UserByUsername(ctx context.Context, username string) (*User, error) UserByUsername(ctx context.Context, username string) (*User, error)
ListUsers(ctx context.Context) ([]User, error) ListUsers(ctx context.Context, q ListUsersQuery) (users []User, nextCursorCreated, nextCursorID string, err error)
CountAdmins(ctx context.Context) (int, error) CountAdmins(ctx context.Context) (int, error)
SetUserRole(ctx context.Context, id string, role Role) error SetUserRole(ctx context.Context, id string, role Role) error
SaveUserProfile(ctx context.Context, u *User) error SaveUserProfile(ctx context.Context, u *User) error
@@ -29,5 +37,6 @@ type Store interface {
GetAnswer(ctx context.Context, questionID string) (*Answer, error) GetAnswer(ctx context.Context, questionID string) (*Answer, error)
UpsertAnswer(ctx context.Context, a *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 Vote(ctx context.Context, userID, questionID string, value int) error
} }
+21 -6
View File
@@ -82,14 +82,14 @@ func (u *User) Create(ctx context.Context) error {
if u.CreatedAt == "" { if u.CreatedAt == "" {
u.CreatedAt = time.Now().UTC().Format(time.RFC3339) u.CreatedAt = time.Now().UTC().Format(time.RFC3339)
} }
return sqlc.New(u.db).CreateUser(ctx, sqlc.CreateUserParams{ return mapUniqueViolation(sqlc.New(u.db).CreateUser(ctx, sqlc.CreateUserParams{
ID: u.ID, ID: u.ID,
Username: u.Username, Username: u.Username,
Name: u.Name, Name: u.Name,
PasswordHash: u.PasswordHash, PasswordHash: u.PasswordHash,
Role: string(u.Role), Role: string(u.Role),
CreatedAt: u.CreatedAt, CreatedAt: u.CreatedAt,
}) }))
} }
// adminRoleLockKey serializes SetRole so concurrent demotions cannot bypass the // adminRoleLockKey serializes SetRole so concurrent demotions cannot bypass the
@@ -171,17 +171,32 @@ func CountAdmins(ctx context.Context, db *sql.DB) (int, error) {
return int(n), err return int(n), err
} }
func ListUsers(ctx context.Context, db *sql.DB) ([]User, error) { func ListUsers(ctx context.Context, db *sql.DB, q ListUsersQuery) ([]User, string, string, error) {
rows, err := sqlc.New(db).ListUsers(ctx, AdminUsersLimit) 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 { if err != nil {
return nil, err return nil, "", "", err
} }
out := make([]User, 0, len(rows)) out := make([]User, 0, len(rows))
for _, r := range rows { for _, r := range rows {
u := toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, "") u := toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, "")
out = append(out, *u) out = append(out, *u)
} }
return out, nil 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) { func UserByID(ctx context.Context, db *sql.DB, id string) (*User, error) {
+41 -17
View File
@@ -3,38 +3,62 @@ package store
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"github.com/jackc/pgx/v5/pgconn"
"plumber/internal/store/sqlc" "plumber/internal/store/sqlc"
) )
// Vote toggles or sets a user's vote on a question (value must be 1 or -1). // ErrDuplicateUsername is returned when inserting a username that already exists.
func Vote(ctx context.Context, db *sql.DB, userID, questionID string, value int) error { var ErrDuplicateUsername = errors.New("username taken")
if value != 1 && value != -1 {
// 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") return fmt.Errorf("invalid vote")
} }
tx, err := db.BeginTx(ctx, nil) q := sqlc.New(db)
if value == 0 {
visible, err := q.QuestionIsVisible(ctx, questionID)
if err != nil { if err != nil {
return err return err
} }
defer tx.Rollback() if !visible {
return ErrHiddenOrMissing
q := sqlc.New(tx)
current, err := q.GetVote(ctx, sqlc.GetVoteParams{UserID: userID, QuestionID: questionID})
if err != nil && err != sql.ErrNoRows {
return err
} }
if err == nil && int(current) == value { return q.DeleteVote(ctx, sqlc.DeleteVoteParams{
err = q.DeleteVote(ctx, sqlc.DeleteVoteParams{UserID: userID, QuestionID: questionID}) UserID: userID,
} else { QuestionID: questionID,
err = q.UpsertVote(ctx, sqlc.UpsertVoteParams{ })
}
n, err := q.UpsertVoteOnVisible(ctx, sqlc.UpsertVoteOnVisibleParams{
UserID: userID, UserID: userID,
QuestionID: questionID, QuestionID: questionID,
Value: int32(value), Value: int32(value),
}) })
}
if err != nil { if err != nil {
return err return mapUniqueViolation(err)
} }
return tx.Commit() 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
} }
+28 -2
View File
@@ -3,6 +3,8 @@ package web
import ( import (
"errors" "errors"
"net/http" "net/http"
"net/url"
"strings"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -13,6 +15,9 @@ type adminUsersPage struct {
page page
Users []store.User Users []store.User
Error string Error string
Search string
NextCursor string
HasMore bool
} }
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) *store.User { func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) *store.User {
@@ -28,14 +33,35 @@ func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
if s.requireAdmin(w, r) == nil { if s.requireAdmin(w, r) == nil {
return return
} }
users, err := s.store.ListUsers(r.Context()) 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 { if err != nil {
http.Error(w, "could not load users", http.StatusInternalServerError) http.Error(w, "could not load users", http.StatusInternalServerError)
return 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{ s.exec(w, "admin-users", adminUsersPage{
page: s.basePage(r, "Users"), page: s.basePage(r, "Users"),
Users: users, Users: users,
Search: search,
NextCursor: next,
HasMore: next != "",
}) })
} }
@@ -50,7 +76,7 @@ func (s *Server) handleAdminSetRole(w http.ResponseWriter, r *http.Request) {
role := store.Role(r.PostFormValue("role")) role := store.Role(r.PostFormValue("role"))
err := s.store.SetUserRole(r.Context(), id, role) err := s.store.SetUserRole(r.Context(), id, role)
if errors.Is(err, store.ErrLastAdmin) { if errors.Is(err, store.ErrLastAdmin) {
users, listErr := s.store.ListUsers(r.Context()) users, _, _, listErr := s.store.ListUsers(r.Context(), store.ListUsersQuery{Limit: store.AdminUsersLimit})
if listErr != nil { if listErr != nil {
http.Error(w, "could not demote last admin", http.StatusBadRequest) http.Error(w, "could not demote last admin", http.StatusBadRequest)
return return
+21 -3
View File
@@ -2,6 +2,9 @@ package web
import ( import (
"crypto/subtle" "crypto/subtle"
"database/sql"
"errors"
"log"
"net/http" "net/http"
"net/url" "net/url"
"regexp" "regexp"
@@ -79,8 +82,16 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
u, err := s.store.UserByUsername(r.Context(), username) u, err := s.store.UserByUsername(r.Context(), username)
hash := loginDummyHash hash := loginDummyHash
if err == nil { switch {
case err == nil:
hash = []byte(u.PasswordHash) 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 { if err != nil || bcrypt.CompareHashAndPassword(hash, []byte(password)) != nil {
s.loginFail.record(loginFailKey(ip, userKey)) s.loginFail.record(loginFailKey(ip, userKey))
@@ -138,7 +149,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
} }
role := store.RoleUser role := store.RoleUser
if setupSecretMatches(s.cfg.AdminSetupSecret, setupSecret) { if setupSecretMatches(s.cfg.AdminSetupSecret, setupSecret) {
role = store.RoleAdmin // store downgrades if an admin already exists role = store.RoleAdmin
} }
u := &store.User{ u := &store.User{
Username: username, Username: username,
@@ -146,12 +157,19 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
Role: role, Role: role,
} }
if err := s.store.CreateUser(r.Context(), u); err != nil { if err := s.store.CreateUser(r.Context(), u); err != nil {
if errors.Is(err, store.ErrDuplicateUsername) {
p.Error = "That username is taken." p.Error = "That username is taken."
s.exec(w, "register", p) s.exec(w, "register", p)
return 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 { if err := s.sessions.RenewToken(r.Context()); err != nil {
http.Error(w, "could not start session", http.StatusInternalServerError) 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 return
} }
s.sessions.Put(r.Context(), "user_id", u.ID) s.sessions.Put(r.Context(), "user_id", u.ID)
+30 -6
View File
@@ -11,7 +11,6 @@ import (
"path" "path"
"strings" "strings"
"github.com/google/uuid"
"golang.org/x/image/draw" "golang.org/x/image/draw"
_ "golang.org/x/image/webp" _ "golang.org/x/image/webp"
@@ -63,6 +62,7 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
} }
avatarURL := "" avatarURL := ""
avatarKey := ""
file, hdr, err := r.FormFile("avatar") file, hdr, err := r.FormFile("avatar")
if err == nil { if err == nil {
defer file.Close() defer file.Close()
@@ -79,9 +79,10 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
s.renderProfile(w, r, u, "Avatar must be a JPEG, PNG, or WebP image.", state) s.renderProfile(w, r, u, "Avatar must be a JPEG, PNG, or WebP image.", state)
return return
} }
key := path.Join("avatars", u.ID, uuid.NewString()+ext) prevURL := u.AvatarURL
avatarKey = path.Join("avatars", u.ID, "avatar"+ext)
url, upErr := s.cfg.Blob.Upload(r.Context(), blob.FileUpload{ url, upErr := s.cfg.Blob.Upload(r.Context(), blob.FileUpload{
Key: key, Key: avatarKey,
Body: bytes.NewReader(body), Body: bytes.NewReader(body),
ContentType: contentType, ContentType: contentType,
Size: int64(len(body)), Size: int64(len(body)),
@@ -91,15 +92,25 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
return return
} }
avatarURL = url 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 { } else if err != http.ErrMissingFile {
s.renderProfile(w, r, u, "Could not read avatar file.", state) s.renderProfile(w, r, u, "Could not read avatar file.", state)
return return
} }
u.State = state u.State = state
if avatarURL != "" {
u.AvatarURL = avatarURL
}
if err := s.store.SaveUserProfile(r.Context(), u); err != nil { if err := s.store.SaveUserProfile(r.Context(), u); err != nil {
http.Error(w, "could not save profile", http.StatusInternalServerError) http.Error(w, "could not save profile", http.StatusInternalServerError)
return return
@@ -108,6 +119,19 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/profile", http.StatusSeeOther) 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 // 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. // 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) { func prepareAvatar(r io.Reader, maxBytes int64) (body []byte, ext, contentType string, err error) {
+23 -4
View File
@@ -3,11 +3,14 @@ package web
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"database/sql"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"html/template" "html/template"
"io/fs" "io/fs"
"log" "log"
"net"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@@ -28,8 +31,8 @@ type Config struct {
// posts the matching setup_secret. It is ignored once any admin exists. // posts the matching setup_secret. It is ignored once any admin exists.
AdminSetupSecret string AdminSetupSecret string
SecureCookie bool SecureCookie bool
// TrustProxy enables X-Forwarded-For / RealIP only behind a known proxy. // TrustedProxies are CIDRs allowed to set X-Forwarded-For (direct peer).
TrustProxy bool TrustedProxies []*net.IPNet
Blob blob.Uploader Blob blob.Uploader
} }
@@ -145,7 +148,7 @@ func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.F
func (s *Server) Handler() http.Handler { func (s *Server) Handler() http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(middleware.RequestID) r.Use(middleware.RequestID)
if s.cfg.TrustProxy { if len(s.cfg.TrustedProxies) > 0 {
r.Use(middleware.RealIP) r.Use(middleware.RealIP)
} }
r.Use(middleware.Logger) r.Use(middleware.Logger)
@@ -356,7 +359,17 @@ func (s *Server) handleQuestion(w http.ResponseWriter, r *http.Request) {
} }
var ans *store.Answer var ans *store.Answer
if q.Answered { if q.Answered {
ans, _ = s.store.GetAnswer(r.Context(), q.ID) 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{ s.exec(w, "question", questionPage{
page: s.basePage(r, q.Title), page: s.basePage(r, q.Title),
@@ -385,11 +398,17 @@ func (s *Server) handleVote(w http.ResponseWriter, r *http.Request) {
value = 1 value = 1
case "-1": case "-1":
value = -1 value = -1
case "0":
value = 0
default: default:
http.Error(w, "invalid vote", http.StatusBadRequest) http.Error(w, "invalid vote", http.StatusBadRequest)
return return
} }
if err := s.store.Vote(r.Context(), u.ID, id, value); err != nil { 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) http.Error(w, "could not vote", http.StatusInternalServerError)
return return
} }
+2
View File
@@ -332,6 +332,8 @@ func (f *fakeBlob) Upload(_ context.Context, obj blob.FileUpload) (string, error
return "https://cdn.example.com/" + obj.Key, nil return "https://cdn.example.com/" + obj.Key, nil
} }
func (f *fakeBlob) Delete(_ context.Context, _ string) error { return nil }
func TestProfilePageAndState(t *testing.T) { func TestProfilePageAndState(t *testing.T) {
srv, mem := newTestServer(t, Config{}) srv, mem := newTestServer(t, Config{})
h := srv.Handler() h := srv.Handler()
+119 -35
View File
@@ -1,22 +1,33 @@
package web package web
import ( import (
"container/list"
"log"
"net" "net"
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
) )
const defaultThrottleMaxKeys = 10_000 const defaultThrottleMaxKeys = 10_000
// throttle is a sliding-window rate limiter with expired-key eviction and a cap. // throttle is a sliding-window rate limiter with LRU eviction at capacity.
type throttle struct { type throttle struct {
mu sync.Mutex mu sync.Mutex
hits map[string][]time.Time hits map[string]*throttleEntry
lru *list.List // front = most recently used
limit int limit int
window time.Duration window time.Duration
maxKeys int 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 { func newThrottle(limit int, window time.Duration, maxKeys int) *throttle {
@@ -24,7 +35,8 @@ func newThrottle(limit int, window time.Duration, maxKeys int) *throttle {
maxKeys = defaultThrottleMaxKeys maxKeys = defaultThrottleMaxKeys
} }
return &throttle{ return &throttle{
hits: map[string][]time.Time{}, hits: map[string]*throttleEntry{},
lru: list.New(),
limit: limit, limit: limit,
window: window, window: window,
maxKeys: maxKeys, maxKeys: maxKeys,
@@ -38,25 +50,60 @@ func (t *throttle) allow(key string) bool {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
now := time.Now() now := time.Now()
t.evictExpiredLocked(now) cutoff := now.Add(-t.window)
xs := pruneTimes(t.hits[key], now.Add(-t.window)) ent, ok := t.hits[key]
if len(xs) >= t.limit { if ok {
if len(xs) == 0 { ent.times = pruneTimes(ent.times, cutoff)
delete(t.hits, key) if len(ent.times) == 0 {
} else { t.removeLocked(key, ent)
t.hits[key] = xs ok = false
} }
}
if ok {
if len(ent.times) >= t.limit {
t.touchLocked(key, ent)
return false return false
} }
if _, exists := t.hits[key]; !exists && len(t.hits) >= t.maxKeys { ent.times = append(ent.times, now)
t.evictExpiredLocked(now) t.touchLocked(key, ent)
if len(t.hits) >= t.maxKeys {
return false
}
}
t.hits[key] = append(xs, now)
return true 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 { func (t *throttle) lenKeys() int {
@@ -65,18 +112,6 @@ func (t *throttle) lenKeys() int {
return len(t.hits) return len(t.hits)
} }
func (t *throttle) evictExpiredLocked(now time.Time) {
cutoff := now.Add(-t.window)
for k, xs := range t.hits {
xs = pruneTimes(xs, cutoff)
if len(xs) == 0 {
delete(t.hits, k)
} else {
t.hits[k] = xs
}
}
}
func pruneTimes(xs []time.Time, cutoff time.Time) []time.Time { func pruneTimes(xs []time.Time, cutoff time.Time) []time.Time {
n := 0 n := 0
for _, ts := range xs { for _, ts := range xs {
@@ -137,8 +172,17 @@ func (f *failureTracker) record(key string) {
f.evictExpiredLocked(now) f.evictExpiredLocked(now)
st := f.fails[key] st := f.fails[key]
if st.count == 0 && len(f.fails) >= f.maxKeys { 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 return
} }
}
st.count++ st.count++
st.last = now st.last = now
f.fails[key] = st f.fails[key] = st
@@ -184,18 +228,58 @@ func progressiveDelay(failCount int) time.Duration {
} }
func (s *Server) clientIP(r *http.Request) string { func (s *Server) clientIP(r *http.Request) string {
if s.cfg.TrustProxy {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return strings.TrimSpace(strings.Split(xff, ",")[0])
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr) host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil { if err != nil {
return r.RemoteAddr 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 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) { func authTooMany(w http.ResponseWriter) {
http.Error(w, "too many attempts; try again later", http.StatusTooManyRequests) http.Error(w, "too many attempts; try again later", http.StatusTooManyRequests)
} }
+11 -6
View File
@@ -1,6 +1,7 @@
package web package web
import ( import (
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sync" "sync"
@@ -33,8 +34,8 @@ func TestThrottleMaxKeys(t *testing.T) {
if !th.allow("one") || !th.allow("two") { if !th.allow("one") || !th.allow("two") {
t.Fatal("first keys should pass") t.Fatal("first keys should pass")
} }
if th.allow("three") { if !th.allow("three") {
t.Fatal("over maxKeys should reject new key") t.Fatal("over maxKeys should LRU-evict and accept new key")
} }
if th.lenKeys() != 2 { if th.lenKeys() != 2 {
t.Fatalf("keys=%d want 2", th.lenKeys()) t.Fatalf("keys=%d want 2", th.lenKeys())
@@ -90,7 +91,11 @@ func TestFailureTrackerEvictsExpired(t *testing.T) {
} }
func TestClientIPTrustProxy(t *testing.T) { func TestClientIPTrustProxy(t *testing.T) {
srv := &Server{cfg: Config{TrustProxy: true}} _, 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 := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.1:1234" req.RemoteAddr = "10.0.0.1:1234"
req.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.1") req.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.1")
@@ -98,9 +103,9 @@ func TestClientIPTrustProxy(t *testing.T) {
t.Fatalf("trusted xff got %q", got) t.Fatalf("trusted xff got %q", got)
} }
srv.cfg.TrustProxy = false req.RemoteAddr = "203.0.113.50:9"
if got := srv.clientIP(req); got != "10.0.0.1" { if got := srv.clientIP(req); got != "203.0.113.50" {
t.Fatalf("untrusted should use RemoteAddr host, got %q", got) t.Fatalf("untrusted peer should ignore xff, got %q", got)
} }
} }
+8
View File
@@ -4,6 +4,11 @@
<p class="eyebrow">Admin</p> <p class="eyebrow">Admin</p>
<h1>Users</h1> <h1>Users</h1>
{{if .Error}}<p class="banner error" role="alert">{{.Error}}</p>{{end}} {{if .Error}}<p class="banner error" role="alert">{{.Error}}</p>{{end}}
<form class="ask" method="get" action="/admin/users" style="margin-bottom:1.5rem">
<label for="q">Search</label>
<input id="q" name="q" type="search" value="{{.Search}}" placeholder="username or name">
<button type="submit" class="btn btn-primary">Search</button>
</form>
<div class="admin-users"> <div class="admin-users">
<table class="user-table"> <table class="user-table">
<thead> <thead>
@@ -40,6 +45,9 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{{if .HasMore}}
<p><a href="{{.NextCursor}}">Next page</a></p>
{{end}}
</main> </main>
{{template "footer" .}} {{template "footer" .}}
{{end}} {{end}}
+2 -2
View File
@@ -5,7 +5,7 @@
hx-post="/questions/{{.Question.ID}}/vote" hx-post="/questions/{{.Question.ID}}/vote"
{{if eq .View "list"}}hx-target="#leaderboard" hx-swap="outerHTML"{{else}}hx-target="#vote-{{.Question.ID}}" hx-swap="outerHTML"{{end}}> {{if eq .View "list"}}hx-target="#leaderboard" hx-swap="outerHTML"{{else}}hx-target="#vote-{{.Question.ID}}" hx-swap="outerHTML"{{end}}>
<input type="hidden" name="_csrf" value="{{.CSRF}}"> <input type="hidden" name="_csrf" value="{{.CSRF}}">
<input type="hidden" name="value" value="1"> {{if eq .Question.UserVote 1}}<input type="hidden" name="value" value="0">{{else}}<input type="hidden" name="value" value="1">{{end}}
<input type="hidden" name="view" value="{{.View}}"> <input type="hidden" name="view" value="{{.View}}">
<input type="hidden" name="date" value="{{.Date}}"> <input type="hidden" name="date" value="{{.Date}}">
<button type="submit" class="vote-btn{{if eq .Question.UserVote 1}} is-up{{end}}" aria-label="Upvote" aria-pressed="{{if eq .Question.UserVote 1}}true{{else}}false{{end}}"> <button type="submit" class="vote-btn{{if eq .Question.UserVote 1}} is-up{{end}}" aria-label="Upvote" aria-pressed="{{if eq .Question.UserVote 1}}true{{else}}false{{end}}">
@@ -17,7 +17,7 @@
hx-post="/questions/{{.Question.ID}}/vote" hx-post="/questions/{{.Question.ID}}/vote"
{{if eq .View "list"}}hx-target="#leaderboard" hx-swap="outerHTML"{{else}}hx-target="#vote-{{.Question.ID}}" hx-swap="outerHTML"{{end}}> {{if eq .View "list"}}hx-target="#leaderboard" hx-swap="outerHTML"{{else}}hx-target="#vote-{{.Question.ID}}" hx-swap="outerHTML"{{end}}>
<input type="hidden" name="_csrf" value="{{.CSRF}}"> <input type="hidden" name="_csrf" value="{{.CSRF}}">
<input type="hidden" name="value" value="-1"> {{if eq .Question.UserVote -1}}<input type="hidden" name="value" value="0">{{else}}<input type="hidden" name="value" value="-1">{{end}}
<input type="hidden" name="view" value="{{.View}}"> <input type="hidden" name="view" value="{{.View}}">
<input type="hidden" name="date" value="{{.Date}}"> <input type="hidden" name="date" value="{{.Date}}">
<button type="submit" class="vote-btn{{if eq .Question.UserVote -1}} is-down{{end}}" aria-label="Downvote" aria-pressed="{{if eq .Question.UserVote -1}}true{{else}}false{{end}}"> <button type="submit" class="vote-btn{{if eq .Question.UserVote -1}} is-down{{end}}" aria-label="Downvote" aria-pressed="{{if eq .Question.UserVote -1}}true{{else}}false{{end}}">