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
+37 -10
View File
@@ -43,7 +43,7 @@ func (m *Memory) CreateUser(_ context.Context, u *User) error {
}
u.Username = NormalizeUsername(u.Username)
if _, ok := m.byName[u.Username]; ok {
return fmt.Errorf("username taken")
return ErrDuplicateUsername
}
if u.ID == "" {
u.ID = uuid.NewString()
@@ -94,18 +94,44 @@ func (m *Memory) UserByUsername(_ context.Context, username string) (*User, erro
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()
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 { return out[i].CreatedAt < out[j].CreatedAt })
if len(out) > AdminUsersLimit {
out = out[:AdminUsersLimit]
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, nil
return out, nextCreated, nextID, nil
}
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 {
m.mu.Lock()
defer m.mu.Unlock()
if value != 1 && value != -1 {
if value != 1 && value != -1 && value != 0 {
return fmt.Errorf("invalid vote")
}
if _, ok := m.questions[questionID]; !ok {
return sql.ErrNoRows
q, ok := m.questions[questionID]
if !ok || q.Hidden {
return ErrHiddenOrMissing
}
if m.votes[questionID] == nil {
m.votes[questionID] = map[string]int{}
}
if cur, ok := m.votes[questionID][userID]; ok && cur == value {
if value == 0 {
delete(m.votes[questionID], userID)
return nil
}
+69
View File
@@ -3,6 +3,7 @@ package store
import (
"database/sql"
"fmt"
"log"
)
// migrateUserProfileColumns adds avatar_url and state when missing (existing DBs).
@@ -16,3 +17,71 @@ func migrateUserProfileColumns(db *sql.DB) error {
}
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()
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()
return nil, nil, fmt.Errorf("apply schema: %w", err)
}
if err := migrateUserProfileColumns(db); err != nil {
_ = db.Close()
return nil, nil, fmt.Errorf("migrate profile columns: %w", err)
return nil, nil, fmt.Errorf("migrate: %w", err)
}
sessions := NewSessionStore(db, 5*time.Minute)
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),
CreatedAt: u.CreatedAt,
}); err != nil {
return err
return mapUniqueViolation(err)
}
if err := tx.Commit(); err != nil {
return err
@@ -89,8 +89,8 @@ func (p *Postgres) UserByUsername(ctx context.Context, username string) (*User,
return UserByUsername(ctx, p.db, username)
}
func (p *Postgres) ListUsers(ctx context.Context) ([]User, error) {
return ListUsers(ctx, p.db)
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) {
+13 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"errors"
"log"
"sync"
"time"
@@ -103,10 +104,21 @@ 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:
_ = 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:
return
}
+7 -4
View File
@@ -10,14 +10,17 @@ import (
"time"
)
const deleteExpiredSessions = `-- name: DeleteExpiredSessions :exec
const deleteExpiredSessions = `-- name: DeleteExpiredSessions :execrows
DELETE FROM sessions
WHERE expiry <= now()
`
func (q *Queries) DeleteExpiredSessions(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, deleteExpiredSessions)
return err
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
+26 -4
View File
@@ -129,10 +129,27 @@ func (q *Queries) GetUserRole(ctx context.Context, id string) (string, error) {
const listUsers = `-- name: ListUsers :many
SELECT id, username, name, role, avatar_url, state, created_at
FROM users
ORDER BY created_at ASC
LIMIT $1
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
@@ -143,8 +160,13 @@ type ListUsersRow struct {
CreatedAt string
}
func (q *Queries) ListUsers(ctx context.Context, rowLimit int32) ([]ListUsersRow, error) {
rows, err := q.db.QueryContext(ctx, listUsers, rowLimit)
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
}
+30 -9
View File
@@ -42,20 +42,41 @@ func (q *Queries) GetVote(ctx context.Context, arg GetVoteParams) (int32, error)
return value, err
}
const upsertVote = `-- name: UpsertVote :exec
INSERT INTO votes (user_id, question_id, value)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, question_id) DO UPDATE
SET value = excluded.value
const questionIsVisible = `-- name: QuestionIsVisible :one
SELECT EXISTS(
SELECT 1 FROM questions WHERE id = $1 AND hidden = 0
)::bool
`
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
QuestionID string
Value int32
}
func (q *Queries) UpsertVote(ctx context.Context, arg UpsertVoteParams) error {
_, err := q.db.ExecContext(ctx, upsertVote, arg.UserID, arg.QuestionID, arg.Value)
return err
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()
}
+11 -2
View File
@@ -6,15 +6,23 @@ import "context"
const (
HuntListLimit = 100
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.
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) ([]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
@@ -29,5 +37,6 @@ type Store interface {
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
}
+21 -6
View File
@@ -82,14 +82,14 @@ func (u *User) Create(ctx context.Context) error {
if u.CreatedAt == "" {
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,
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
@@ -171,17 +171,32 @@ func CountAdmins(ctx context.Context, db *sql.DB) (int, error) {
return int(n), err
}
func ListUsers(ctx context.Context, db *sql.DB) ([]User, error) {
rows, err := sqlc.New(db).ListUsers(ctx, AdminUsersLimit)
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
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)
}
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) {
+45 -21
View File
@@ -3,38 +3,62 @@ package store
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgconn"
"plumber/internal/store/sqlc"
)
// Vote toggles or sets a user's vote on a question (value must be 1 or -1).
func Vote(ctx context.Context, db *sql.DB, userID, questionID string, value int) error {
if value != 1 && value != -1 {
// 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")
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
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 {
err = q.DeleteVote(ctx, sqlc.DeleteVoteParams{UserID: userID, QuestionID: questionID})
} else {
err = q.UpsertVote(ctx, sqlc.UpsertVoteParams{
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,
Value: int32(value),
})
}
n, err := q.UpsertVoteOnVisible(ctx, sqlc.UpsertVoteOnVisibleParams{
UserID: userID,
QuestionID: questionID,
Value: int32(value),
})
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
}