diff --git a/internal/blob/spaces.go b/internal/blob/spaces.go index 979e7d9..1a69d36 100644 --- a/internal/blob/spaces.go +++ b/internal/blob/spaces.go @@ -16,11 +16,11 @@ import ( // Uploader stores public avatar objects. type Uploader interface { Enabled() bool - Upload(ctx context.Context, obj Object) (publicURL string, err error) + Upload(ctx context.Context, obj FileUpload) (publicURL string, err error) } -// Object is a file to upload to object storage. -type Object struct { +// FileUpload is a file body to store (e.g. an avatar). +type FileUpload struct { Key string Body io.Reader ContentType string @@ -47,7 +47,7 @@ type spaces struct { func (Disabled) Enabled() bool { return false } -func (Disabled) Upload(context.Context, Object) (string, error) { +func (Disabled) Upload(context.Context, FileUpload) (string, error) { return "", fmt.Errorf("avatar uploads are not configured") } @@ -84,7 +84,7 @@ func NewSpaces(cfg SpacesConfig) Uploader { func (s *spaces) Enabled() bool { return true } -func (s *spaces) Upload(ctx context.Context, obj Object) (string, error) { +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), diff --git a/internal/store/postgres.go b/internal/store/postgres.go index b9364ab..bd14f7d 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -4,34 +4,12 @@ import ( "database/sql" "fmt" "net/url" - "strconv" "strings" "time" _ "github.com/jackc/pgx/v5/stdlib" ) -// rebind converts ? placeholders to Postgres $1, $2, ... form. -func rebind(query string) string { - n := 0 - var b strings.Builder - for i := 0; i < len(query); i++ { - if query[i] == '?' { - n++ - b.WriteByte('$') - b.WriteString(strconv.Itoa(n)) - continue - } - b.WriteByte(query[i]) - } - return b.String() -} - -// q rebinds SQL placeholders for Postgres. -func (s *Store) q(query string) string { - return rebind(query) -} - // applySchema runs semicolon-separated DDL statements, skipping PRAGMA lines. func applySchema(db *sql.DB, schema string) error { for _, stmt := range strings.Split(schema, ";") { diff --git a/internal/store/postgres_test.go b/internal/store/postgres_test.go index f297d14..c854834 100644 --- a/internal/store/postgres_test.go +++ b/internal/store/postgres_test.go @@ -2,14 +2,6 @@ package store import "testing" -func TestRebindPostgresPlaceholders(t *testing.T) { - got := rebind(`SELECT a FROM t WHERE x = ? AND y = ?`) - want := `SELECT a FROM t WHERE x = $1 AND y = $2` - if got != want { - t.Fatalf("got %q", got) - } -} - func TestNormalizeUsername(t *testing.T) { if got := NormalizeUsername(" Alice_1 "); got != "alice_1" { t.Fatalf("got %q", got) diff --git a/internal/store/store.go b/internal/store/store.go index ccfe95a..9c93af8 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -79,7 +79,7 @@ func (s *Store) CreateUser(ctx context.Context, nu NewUser) (*User, error) { PasswordHash: nu.PasswordHash, CreatedAt: time.Now().UTC().Format(time.RFC3339), } - _, err := s.db.ExecContext(ctx, s.q(`INSERT INTO users (id, username, name, password_hash, role, avatar_url, state, created_at) VALUES (?, ?, ?, ?, ?, '', '', ?)`), + _, err := s.db.ExecContext(ctx, `INSERT INTO users (id, username, name, password_hash, role, avatar_url, state, created_at) VALUES ($1, $2, $3, $4, $5, '', '', $6)`, u.ID, u.Username, u.Name, u.PasswordHash, string(u.Role), u.CreatedAt) if err != nil { return nil, err @@ -89,12 +89,12 @@ func (s *Store) CreateUser(ctx context.Context, nu NewUser) (*User, error) { func (s *Store) CountAdmins(ctx context.Context) (int, error) { var n int - err := s.db.QueryRowContext(ctx, s.q(`SELECT COUNT(*) FROM users WHERE role = ?`), string(RoleAdmin)).Scan(&n) + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = $1`, string(RoleAdmin)).Scan(&n) return n, err } func (s *Store) ListUsers(ctx context.Context) ([]User, error) { - rows, err := s.db.QueryContext(ctx, s.q(`SELECT id, username, name, role, avatar_url, state, created_at FROM users ORDER BY created_at ASC`)) + rows, err := s.db.QueryContext(ctx, `SELECT id, username, name, role, avatar_url, state, created_at FROM users ORDER BY created_at ASC`) if err != nil { return nil, err } @@ -123,20 +123,20 @@ func (s *Store) SetRole(ctx context.Context, userID string, role Role) error { defer tx.Rollback() var current string - err = tx.QueryRowContext(ctx, s.q(`SELECT role FROM users WHERE id = ?`), userID).Scan(¤t) + err = tx.QueryRowContext(ctx, `SELECT role FROM users WHERE id = $1`, userID).Scan(¤t) if err != nil { return err } if Role(current) == RoleAdmin && role == RoleUser { var n int - if err := tx.QueryRowContext(ctx, s.q(`SELECT COUNT(*) FROM users WHERE role = ?`), string(RoleAdmin)).Scan(&n); err != nil { + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = $1`, string(RoleAdmin)).Scan(&n); err != nil { return err } if n <= 1 { return ErrLastAdmin } } - res, err := tx.ExecContext(ctx, s.q(`UPDATE users SET role = ? WHERE id = ?`), string(role), userID) + res, err := tx.ExecContext(ctx, `UPDATE users SET role = $1 WHERE id = $2`, string(role), userID) if err != nil { return err } @@ -151,11 +151,11 @@ func (s *Store) SetRole(ctx context.Context, userID string, role Role) error { } func (s *Store) UserByID(ctx context.Context, id string) (*User, error) { - return scanUser(s.db.QueryRowContext(ctx, s.q(`SELECT id, username, name, role, avatar_url, state, created_at FROM users WHERE id = ?`), id), false) + return scanUser(s.db.QueryRowContext(ctx, `SELECT id, username, name, role, avatar_url, state, created_at FROM users WHERE id = $1`, id), false) } func (s *Store) UserByUsername(ctx context.Context, username string) (*User, error) { - return scanUser(s.db.QueryRowContext(ctx, s.q(`SELECT id, username, name, role, avatar_url, state, created_at, password_hash FROM users WHERE username = ?`), NormalizeUsername(username)), true) + return scanUser(s.db.QueryRowContext(ctx, `SELECT id, username, name, role, avatar_url, state, created_at, password_hash FROM users WHERE username = $1`, NormalizeUsername(username)), true) } func scanUser(row *sql.Row, withSecrets bool) (*User, error) { @@ -188,7 +188,7 @@ func (s *Store) CreateQuestion(ctx context.Context, authorID, title, body, city HuntDate: pacific.Today(), CreatedAt: time.Now().UTC().Format(time.RFC3339), } - _, err := s.db.ExecContext(ctx, s.q(`INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at) VALUES (?, ?, ?, ?, ?, ?, 0, ?)`), + _, err := s.db.ExecContext(ctx, `INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at) VALUES ($1, $2, $3, $4, $5, $6, 0, $7)`, q.ID, q.AuthorID, q.Title, q.Body, q.City, q.HuntDate, q.CreatedAt) if err != nil { return nil, err @@ -197,18 +197,18 @@ func (s *Store) CreateQuestion(ctx context.Context, authorID, title, body, city } func (s *Store) ListHunt(ctx context.Context, huntDate, viewerID string) ([]RankedQuestion, error) { - rows, err := s.db.QueryContext(ctx, s.q(` + rows, err := s.db.QueryContext(ctx, ` SELECT q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, COALESCE(SUM(v.value), 0) AS score, CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END AS answered, - COALESCE((SELECT value FROM votes WHERE user_id = ? AND question_id = q.id), 0) AS user_vote + COALESCE((SELECT value FROM votes WHERE user_id = $1 AND question_id = q.id), 0) 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 = ? AND q.hidden = 0 +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`), viewerID, huntDate) +ORDER BY score DESC, q.created_at ASC`, viewerID, huntDate) if err != nil { return nil, err } @@ -225,15 +225,15 @@ ORDER BY score DESC, q.created_at ASC`), viewerID, huntDate) } func (s *Store) GetQuestion(ctx context.Context, id, viewerID string) (*RankedQuestion, error) { - row := s.db.QueryRowContext(ctx, s.q(` + row := s.db.QueryRowContext(ctx, ` SELECT q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, COALESCE((SELECT SUM(value) FROM votes WHERE question_id = q.id), 0) AS score, CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END AS answered, - COALESCE((SELECT value FROM votes WHERE user_id = ? AND question_id = q.id), 0) AS user_vote + COALESCE((SELECT value FROM votes WHERE user_id = $1 AND question_id = q.id), 0) 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 = ?`), viewerID, id) +WHERE q.id = $2`, viewerID, id) q, err := scanRankedRow(row) if err != nil { return nil, err @@ -268,15 +268,15 @@ func (s *Store) Vote(ctx context.Context, userID, questionID string, value int) } defer tx.Rollback() var current sql.NullInt64 - err = tx.QueryRowContext(ctx, s.q(`SELECT value FROM votes WHERE user_id = ? AND question_id = ?`), userID, questionID).Scan(¤t) + err = tx.QueryRowContext(ctx, `SELECT value FROM votes WHERE user_id = $1 AND question_id = $2`, userID, questionID).Scan(¤t) if err != nil && err != sql.ErrNoRows { return err } if err == nil && current.Valid && int(current.Int64) == value { - _, err = tx.ExecContext(ctx, s.q(`DELETE FROM votes WHERE user_id = ? AND question_id = ?`), userID, questionID) + _, err = tx.ExecContext(ctx, `DELETE FROM votes WHERE user_id = $1 AND question_id = $2`, userID, questionID) } else { - _, err = tx.ExecContext(ctx, s.q(`INSERT INTO votes (user_id, question_id, value) VALUES (?, ?, ?) -ON CONFLICT (user_id, question_id) DO UPDATE SET value = excluded.value`), userID, questionID, value) + _, err = tx.ExecContext(ctx, `INSERT INTO votes (user_id, question_id, value) VALUES ($1, $2, $3) +ON CONFLICT (user_id, question_id) DO UPDATE SET value = excluded.value`, userID, questionID, value) } if err != nil { return err @@ -286,11 +286,11 @@ ON CONFLICT (user_id, question_id) DO UPDATE SET value = excluded.value`), userI func (s *Store) GetAnswer(ctx context.Context, questionID string) (*Answer, error) { var a Answer - err := s.db.QueryRowContext(ctx, s.q(` + err := s.db.QueryRowContext(ctx, ` SELECT a.question_id, a.author_id, u.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 = ?`), questionID).Scan(&a.QuestionID, &a.AuthorID, &a.AuthorName, &a.Body, &a.CreatedAt, &a.UpdatedAt) +WHERE a.question_id = $1`, questionID).Scan(&a.QuestionID, &a.AuthorID, &a.AuthorName, &a.Body, &a.CreatedAt, &a.UpdatedAt) if err != nil { return nil, err } @@ -300,30 +300,30 @@ WHERE a.question_id = ?`), questionID).Scan(&a.QuestionID, &a.AuthorID, &a.Autho func (s *Store) UpsertAnswer(ctx context.Context, questionID, authorID, body string) error { body = strings.TrimSpace(body) now := time.Now().UTC().Format(time.RFC3339) - _, err := s.db.ExecContext(ctx, s.q(` -INSERT INTO answers (question_id, author_id, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?) -ON CONFLICT (question_id) DO UPDATE SET body = excluded.body, author_id = excluded.author_id, updated_at = excluded.updated_at`), + _, err := s.db.ExecContext(ctx, ` +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`, questionID, authorID, body, now, now) return err } func (s *Store) HideQuestion(ctx context.Context, id string) error { - _, err := s.db.ExecContext(ctx, s.q(`UPDATE questions SET hidden = 1 WHERE id = ?`), id) + _, err := s.db.ExecContext(ctx, `UPDATE questions SET hidden = 1 WHERE id = $1`, id) return err } func (s *Store) UpdateProfile(ctx context.Context, userID, state, avatarURL string) error { state = strings.TrimSpace(state) if avatarURL == "" { - _, err := s.db.ExecContext(ctx, s.q(`UPDATE users SET state = ? WHERE id = ?`), state, userID) + _, err := s.db.ExecContext(ctx, `UPDATE users SET state = $1 WHERE id = $2`, state, userID) return err } - _, err := s.db.ExecContext(ctx, s.q(`UPDATE users SET state = ?, avatar_url = ? WHERE id = ?`), state, avatarURL, userID) + _, err := s.db.ExecContext(ctx, `UPDATE users SET state = $1, avatar_url = $2 WHERE id = $3`, state, avatarURL, userID) return err } func (s *Store) ListQuestionsByAuthor(ctx context.Context, authorID string) ([]RankedQuestion, error) { - rows, err := s.db.QueryContext(ctx, s.q(` + rows, err := s.db.QueryContext(ctx, ` SELECT q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, COALESCE((SELECT SUM(value) FROM votes WHERE question_id = q.id), 0) AS score, CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END AS answered, @@ -331,8 +331,8 @@ SELECT q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden 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 = ? AND q.hidden = 0 -ORDER BY q.created_at DESC`), authorID) +WHERE q.author_id = $1 AND q.hidden = 0 +ORDER BY q.created_at DESC`, authorID) if err != nil { return nil, err } @@ -341,7 +341,7 @@ ORDER BY q.created_at DESC`), authorID) } func (s *Store) ListQuestionsAnsweredBy(ctx context.Context, adminID string) ([]RankedQuestion, error) { - rows, err := s.db.QueryContext(ctx, s.q(` + rows, err := s.db.QueryContext(ctx, ` SELECT q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, COALESCE((SELECT SUM(value) FROM votes WHERE question_id = q.id), 0) AS score, 1 AS answered, @@ -349,8 +349,8 @@ SELECT q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden 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 = ? AND q.hidden = 0 -ORDER BY ans.updated_at DESC`), adminID) +WHERE ans.author_id = $1 AND q.hidden = 0 +ORDER BY ans.updated_at DESC`, adminID) if err != nil { return nil, err } diff --git a/internal/web/profile.go b/internal/web/profile.go index c603885..0ec071d 100644 --- a/internal/web/profile.go +++ b/internal/web/profile.go @@ -75,7 +75,7 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { } key := path.Join("avatars", u.ID, uuid.NewString()+ext) limited := io.LimitReader(file, (2<<20)+1) - url, upErr := s.cfg.Blob.Upload(r.Context(), blob.Object{ + url, upErr := s.cfg.Blob.Upload(r.Context(), blob.FileUpload{ Key: key, Body: limited, ContentType: contentType, diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 57a1aed..130157c 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -296,7 +296,7 @@ type fakeBlob struct { func (f *fakeBlob) Enabled() bool { return true } -func (f *fakeBlob) Upload(_ context.Context, obj blob.Object) (string, error) { +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