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:
+37
-10
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user