diff --git a/.env.example b/.env.example index e6941bb..a4d043c 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,13 @@ # Local listen address (ignored when PORT is set, e.g. on App Platform) LISTEN=:8080 -DATA_PATH=data.db +# Required: PlanetScale Postgres URI (port 5432 so the app can create tables on boot). +# Switch to 6432 (PgBouncer) later if you need pooling. +DATABASE_URL=postgresql://user:password@host.example.com:5432/postgres?sslmode=verify-full # Optional: first matching registrant becomes admin only if no admin exists yet. # Later promote/demote via /admin/users (admins only). ADMIN_USERNAME=yourusername # Set to 1 when serving over HTTPS SECURE_COOKIE=0 -# Prod (PlanetScale Postgres): paste the dashboard URI. When set, SQLite is ignored. -# Use port 5432 so the app can create tables on boot. Switch to 6432 (PgBouncer) later if you need pooling. -# DATABASE_URL=postgresql://user:password@host.example.com:5432/postgres?sslmode=verify-full # DigitalOcean Spaces (profile avatars). Leave unset to disable uploads. # SPACES_KEY= # SPACES_SECRET= diff --git a/cmd/server/main.go b/cmd/server/main.go index 169fa9a..b482bf9 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,10 +1,15 @@ package main import ( + "context" + "errors" "log" "net/http" "os" + "os/signal" "strings" + "syscall" + "time" "github.com/joho/godotenv" @@ -17,29 +22,25 @@ import ( func main() { _ = godotenv.Load() listen := listenAddr() - st, err := store.Connect(os.Getenv("DATABASE_URL"), env("DATA_PATH", "data.db"), plumber.SchemaSQL) + + databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL")) + if databaseURL == "" { + log.Fatal("DATABASE_URL is required") + } + st, err := store.OpenPostgres(databaseURL, plumber.SchemaSQL) if err != nil { log.Fatalf("database: %v", err) } defer st.Close() - if os.Getenv("DATABASE_URL") != "" { - log.Printf("database: postgres") - } else { - log.Printf("database: sqlite") - } - uploader := blob.NewSpaces(blob.SpacesConfig{ - Key: os.Getenv("SPACES_KEY"), - Secret: os.Getenv("SPACES_SECRET"), - Region: os.Getenv("SPACES_REGION"), - Bucket: os.Getenv("SPACES_BUCKET"), - Endpoint: os.Getenv("SPACES_ENDPOINT"), - CDNBase: os.Getenv("SPACES_CDN_BASE"), - }) + log.Printf("database: postgres") + + uploader := spacesUploader() if uploader.Enabled() { log.Printf("avatars: digitalocean spaces") } else { log.Printf("avatars: uploads disabled (set SPACES_* to enable)") } + srv, err := web.New(st, st.SessionStore(), plumber.TemplateFS, plumber.StaticFS, web.Config{ AdminUsername: os.Getenv("ADMIN_USERNAME"), SecureCookie: os.Getenv("SECURE_COOKIE") == "1", @@ -48,12 +49,46 @@ func main() { if err != nil { log.Fatalf("server: %v", err) } - log.Printf("listening on %s", listen) - if err := http.ListenAndServe(listen, srv.Handler()); err != nil { - log.Fatal(err) + + httpSrv := &http.Server{Addr: listen, Handler: srv.Handler()} + errCh := make(chan error, 1) + go func() { + log.Printf("listening on %s", listen) + errCh <- httpSrv.ListenAndServe() + }() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + + select { + case err := <-errCh: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatal(err) + } + case sig := <-sigCh: + log.Printf("shutdown signal: %v", sig) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := httpSrv.Shutdown(ctx); err != nil { + log.Printf("shutdown: %v", err) + } + if err := <-errCh; err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatal(err) + } } } +func spacesUploader() blob.Uploader { + return blob.NewSpaces(blob.SpacesConfig{ + Key: os.Getenv("SPACES_KEY"), + Secret: os.Getenv("SPACES_SECRET"), + Region: os.Getenv("SPACES_REGION"), + Bucket: os.Getenv("SPACES_BUCKET"), + Endpoint: os.Getenv("SPACES_ENDPOINT"), + CDNBase: os.Getenv("SPACES_CDN_BASE"), + }) +} + // listenAddr prefers PORT (App Platform / PaaS), then LISTEN, then :8080. func listenAddr() string { if p := strings.TrimSpace(os.Getenv("PORT")); p != "" { diff --git a/go.mod b/go.mod index a4ba585..84dc9b5 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,6 @@ require ( github.com/jackc/pgx/v5 v5.10.0 github.com/joho/godotenv v1.5.1 golang.org/x/crypto v0.55.0 - modernc.org/sqlite v1.57.0 ) require ( @@ -26,17 +25,9 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.38 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.39 // indirect github.com/aws/smithy-go v1.27.8 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/mattn/go-isatty v0.0.24 // indirect - github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect - modernc.org/libc v1.74.4 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 66a4c4b..88aefcd 100644 --- a/go.sum +++ b/go.sum @@ -29,16 +29,10 @@ github.com/aws/smithy-go v1.27.8/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqx github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= -github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= -github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -51,14 +45,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/lib/pq v1.4.0 h1:TmtCFbH+Aw0AixwyttznSMQDgbR5Yed/Gg6S8Funrhc= github.com/lib/pq v1.4.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= -github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= -github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= -github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -66,45 +54,11 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= -golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= -golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= -golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= -modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= -modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= -modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= -modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= -modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= -modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= -modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= -modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= -modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= -modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= -modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= -modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= -modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg= -modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= -modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/blob/spaces.go b/internal/blob/spaces.go index 69df506..4e0d8cd 100644 --- a/internal/blob/spaces.go +++ b/internal/blob/spaces.go @@ -21,12 +21,6 @@ type Uploader interface { // Disabled is a no-op uploader used when Spaces is not configured. type Disabled struct{} -func (Disabled) Enabled() bool { return false } - -func (Disabled) Upload(context.Context, string, io.Reader, string, int64) (string, error) { - return "", fmt.Errorf("avatar uploads are not configured") -} - // SpacesConfig holds DigitalOcean Spaces settings. type SpacesConfig struct { Key string @@ -37,6 +31,17 @@ type SpacesConfig struct { CDNBase string // optional public base URL without trailing slash } +type spaces struct { + client *s3.Client + cfg SpacesConfig +} + +func (Disabled) Enabled() bool { return false } + +func (Disabled) Upload(context.Context, string, io.Reader, string, int64) (string, error) { + return "", fmt.Errorf("avatar uploads are not configured") +} + // NewSpaces returns an Uploader when required env is present; otherwise Disabled. func NewSpaces(cfg SpacesConfig) Uploader { cfg.Key = strings.TrimSpace(cfg.Key) @@ -49,18 +54,13 @@ func NewSpaces(cfg SpacesConfig) Uploader { return Disabled{} } client := s3.New(s3.Options{ - Region: cfg.Region, - Credentials: credentials.NewStaticCredentialsProvider(cfg.Key, cfg.Secret, ""), + Region: cfg.Region, + Credentials: credentials.NewStaticCredentialsProvider(cfg.Key, cfg.Secret, ""), BaseEndpoint: aws.String(cfg.Endpoint), }) return &spaces{client: client, cfg: cfg} } -type spaces struct { - client *s3.Client - cfg SpacesConfig -} - func (s *spaces) Enabled() bool { return true } func (s *spaces) Upload(ctx context.Context, key string, body io.Reader, contentType string, size int64) (string, error) { diff --git a/internal/store/db.go b/internal/store/db.go index 00febb9..45514bc 100644 --- a/internal/store/db.go +++ b/internal/store/db.go @@ -8,15 +8,30 @@ import ( // ErrLastAdmin is returned when demoting the only remaining admin. var ErrLastAdmin = errors.New("cannot demote the last admin") +// Role is a user privilege level stored in users.role. +type Role string + +const ( + RoleUser Role = "user" + RoleAdmin Role = "admin" +) + +// NewUser is the input for CreateUser. +type NewUser struct { + Username string + PasswordHash string + Role Role +} + // DB is the persistence API used by the web layer. // Named DB to avoid colliding with scs.Store. type DB interface { - CreateUser(ctx context.Context, username, passwordHash string, asAdmin bool) (*User, error) + CreateUser(ctx context.Context, user NewUser) (*User, error) UserByID(ctx context.Context, id string) (*User, error) UserByUsername(ctx context.Context, username string) (*User, error) CountAdmins(ctx context.Context) (int, error) ListUsers(ctx context.Context) ([]User, error) - SetRole(ctx context.Context, userID, role string) error + SetRole(ctx context.Context, userID string, role Role) error CreateQuestion(ctx context.Context, authorID, title, body, city string) (*RankedQuestion, error) ListHunt(ctx context.Context, huntDate, viewerID string) ([]RankedQuestion, error) GetQuestion(ctx context.Context, id, viewerID string) (*RankedQuestion, error) diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 0c247cb..bec080c 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -3,24 +3,14 @@ package store import ( "database/sql" "fmt" - "strings" ) -func migrateUserProfileColumns(db *sql.DB, dialect string) error { +// migrateUserProfileColumns adds avatar_url and state when missing (existing DBs). +func migrateUserProfileColumns(db *sql.DB) error { cols := []string{"avatar_url", "state"} for _, col := range cols { - var stmt string - switch dialect { - case dialectPostgres: - stmt = fmt.Sprintf(`ALTER TABLE users ADD COLUMN IF NOT EXISTS %s TEXT NOT NULL DEFAULT ''`, col) - default: - stmt = fmt.Sprintf(`ALTER TABLE users ADD COLUMN %s TEXT NOT NULL DEFAULT ''`, col) - } + stmt := fmt.Sprintf(`ALTER TABLE users ADD COLUMN IF NOT EXISTS %s TEXT NOT NULL DEFAULT ''`, col) if _, err := db.Exec(stmt); err != nil { - // SQLite errors when the column already exists. - if dialect == dialectSQLite && strings.Contains(strings.ToLower(err.Error()), "duplicate column") { - continue - } return fmt.Errorf("add column %s: %w", col, err) } } diff --git a/internal/store/postgres.go b/internal/store/postgres.go index 878e2d0..b9364ab 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -11,11 +11,7 @@ import ( _ "github.com/jackc/pgx/v5/stdlib" ) -const ( - dialectSQLite = "sqlite" - dialectPostgres = "postgres" -) - +// rebind converts ? placeholders to Postgres $1, $2, ... form. func rebind(query string) string { n := 0 var b strings.Builder @@ -31,13 +27,12 @@ func rebind(query string) string { return b.String() } +// q rebinds SQL placeholders for Postgres. func (s *Store) q(query string) string { - if s.dialect == dialectPostgres { - return rebind(query) - } - return query + 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, ";") { stmt = strings.TrimSpace(stmt) @@ -55,6 +50,7 @@ func applySchema(db *sql.DB, schema string) error { return nil } +// postgresDSN normalizes DATABASE_URL for pgx (sslmode default, strip unsupported params). func postgresDSN(raw string) (string, error) { u, err := url.Parse(raw) if err != nil { @@ -77,16 +73,8 @@ func postgresDSN(raw string) (string, error) { return u.String(), nil } +// OpenPostgres connects to Postgres, applies schema/migrations, and starts session cleanup. func OpenPostgres(databaseURL, schema string) (*Store, error) { - return openPostgres(databaseURL, schema, 5*time.Minute) -} - -// OpenPostgresWithoutSessionCleanup opens Postgres without a session cleanup goroutine (for tests). -func OpenPostgresWithoutSessionCleanup(databaseURL, schema string) (*Store, error) { - return openPostgres(databaseURL, schema, 0) -} - -func openPostgres(databaseURL, schema string, sessionCleanup time.Duration) (*Store, error) { dsn, err := postgresDSN(databaseURL) if err != nil { return nil, err @@ -105,26 +93,15 @@ func openPostgres(databaseURL, schema string, sessionCleanup time.Duration) (*St _ = db.Close() return nil, fmt.Errorf("apply schema: %w", err) } - if err := applySessionsSchema(db, dialectPostgres); err != nil { + if err := applySessionsSchema(db); err != nil { _ = db.Close() return nil, fmt.Errorf("apply sessions schema: %w", err) } - if err := migrateUserProfileColumns(db, dialectPostgres); err != nil { + if err := migrateUserProfileColumns(db); err != nil { _ = db.Close() return nil, fmt.Errorf("migrate profile columns: %w", err) } - st := &Store{db: db, dialect: dialectPostgres} - st.initSessionStore(sessionCleanup) + st := &Store{db: db} + st.initSessionStore(5 * time.Minute) return st, nil } - -// Connect uses PlanetScale Postgres when DATABASE_URL is set, otherwise SQLite. -func Connect(databaseURL, sqlitePath, schema string) (*Store, error) { - if strings.TrimSpace(databaseURL) != "" { - return OpenPostgres(databaseURL, schema) - } - if sqlitePath == "" { - sqlitePath = "data.db" - } - return Open(sqlitePath, schema) -} diff --git a/internal/store/sessions.go b/internal/store/sessions.go index 1e4041f..33c507c 100644 --- a/internal/store/sessions.go +++ b/internal/store/sessions.go @@ -2,22 +2,12 @@ package store import ( "database/sql" - "log" "time" "github.com/alexedwards/scs/postgresstore" "github.com/alexedwards/scs/v2" ) -const sessionsSchemaSQLite = ` -CREATE TABLE IF NOT EXISTS sessions ( - token TEXT PRIMARY KEY, - data BLOB NOT NULL, - expiry REAL NOT NULL -); -CREATE INDEX IF NOT EXISTS sessions_expiry_idx ON sessions(expiry); -` - const sessionsSchemaPostgres = ` CREATE TABLE IF NOT EXISTS sessions ( token TEXT PRIMARY KEY, @@ -27,12 +17,9 @@ CREATE TABLE IF NOT EXISTS sessions ( CREATE INDEX IF NOT EXISTS sessions_expiry_idx ON sessions (expiry); ` -func applySessionsSchema(db *sql.DB, dialect string) error { - schema := sessionsSchemaSQLite - if dialect == dialectPostgres { - schema = sessionsSchemaPostgres - } - return applySchema(db, schema) +// applySessionsSchema creates the scs sessions table if missing. +func applySessionsSchema(db *sql.DB) error { + return applySchema(db, sessionsSchemaPostgres) } type sessionStopper interface { @@ -45,110 +32,7 @@ func (s *Store) SessionStore() scs.Store { } func (s *Store) initSessionStore(cleanupInterval time.Duration) { - switch s.dialect { - case dialectPostgres: - ps := postgresstore.NewWithCleanupInterval(s.db, cleanupInterval) - s.sessionStore = ps - s.sessionStopper = ps - default: - ss := newSQLiteSessionStore(s.db, cleanupInterval) - s.sessionStore = ss - s.sessionStopper = ss - } + ps := postgresstore.NewWithCleanupInterval(s.db, cleanupInterval) + s.sessionStore = ps + s.sessionStopper = ps } - -// sqliteSessionStore is a modernc-safe scs.Store (uses ? placeholders). -type sqliteSessionStore struct { - db *sql.DB - stopCleanup chan bool -} - -func newSQLiteSessionStore(db *sql.DB, cleanupInterval time.Duration) *sqliteSessionStore { - s := &sqliteSessionStore{db: db} - if cleanupInterval > 0 { - s.stopCleanup = make(chan bool) - go s.startCleanup(cleanupInterval) - } - return s -} - -func (s *sqliteSessionStore) Find(token string) ([]byte, bool, error) { - var b []byte - err := s.db.QueryRow( - `SELECT data FROM sessions WHERE token = ? AND julianday('now') < expiry`, - token, - ).Scan(&b) - if err == sql.ErrNoRows { - return nil, false, nil - } - if err != nil { - return nil, false, err - } - return b, true, nil -} - -func (s *sqliteSessionStore) Commit(token string, b []byte, expiry time.Time) error { - _, err := s.db.Exec( - `REPLACE INTO sessions (token, data, expiry) VALUES (?, ?, julianday(?))`, - token, - b, - expiry.UTC().Format("2006-01-02T15:04:05.999"), - ) - return err -} - -func (s *sqliteSessionStore) Delete(token string) error { - _, err := s.db.Exec(`DELETE FROM sessions WHERE token = ?`, token) - return err -} - -func (s *sqliteSessionStore) All() (map[string][]byte, error) { - rows, err := s.db.Query(`SELECT token, data FROM sessions WHERE julianday('now') < expiry`) - if err != nil { - return nil, err - } - defer rows.Close() - out := make(map[string][]byte) - for rows.Next() { - var token string - var data []byte - if err := rows.Scan(&token, &data); err != nil { - return nil, err - } - out[token] = data - } - return out, rows.Err() -} - -func (s *sqliteSessionStore) startCleanup(interval time.Duration) { - ticker := time.NewTicker(interval) - for { - select { - case <-ticker.C: - if err := s.deleteExpired(); err != nil { - log.Println(err) - } - case <-s.stopCleanup: - ticker.Stop() - return - } - } -} - -func (s *sqliteSessionStore) StopCleanup() { - if s.stopCleanup != nil { - s.stopCleanup <- true - } -} - -func (s *sqliteSessionStore) deleteExpired() error { - _, err := s.db.Exec(`DELETE FROM sessions WHERE expiry < julianday('now')`) - return err -} - -// Ensure interface compliance. -var ( - _ scs.Store = (*sqliteSessionStore)(nil) - _ scs.IterableStore = (*sqliteSessionStore)(nil) - _ sessionStopper = (*sqliteSessionStore)(nil) -) diff --git a/internal/store/store.go b/internal/store/store.go index 778dfc8..ccfe95a 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -9,14 +9,12 @@ import ( "github.com/alexedwards/scs/v2" "github.com/google/uuid" - _ "modernc.org/sqlite" "plumber/internal/pacific" ) type Store struct { db *sql.DB - dialect string sessionStore scs.Store sessionStopper sessionStopper } @@ -25,7 +23,7 @@ type User struct { ID string Username string Name string - Role string + Role Role AvatarURL string State string CreatedAt string @@ -33,7 +31,7 @@ type User struct { } func (u *User) Admin() bool { - return u != nil && u.Role == "admin" + return u != nil && u.Role == RoleAdmin } type RankedQuestion struct { @@ -60,42 +58,6 @@ type Answer struct { UpdatedAt string } -func Open(path, schema string) (*Store, error) { - return openSQLite(path, schema, 5*time.Minute) -} - -// OpenWithoutSessionCleanup opens SQLite without a session cleanup goroutine (for tests). -func OpenWithoutSessionCleanup(path, schema string) (*Store, error) { - return openSQLite(path, schema, 0) -} - -func openSQLite(path, schema string, sessionCleanup time.Duration) (*Store, error) { - dsn := path - if !strings.Contains(dsn, "?") { - dsn += "?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)" - } - db, err := sql.Open("sqlite", dsn) - if err != nil { - return nil, err - } - db.SetMaxOpenConns(1) - if _, err := db.Exec(schema); err != nil { - _ = db.Close() - return nil, fmt.Errorf("apply schema: %w", err) - } - if err := applySessionsSchema(db, dialectSQLite); err != nil { - _ = db.Close() - return nil, fmt.Errorf("apply sessions schema: %w", err) - } - if err := migrateUserProfileColumns(db, dialectSQLite); err != nil { - _ = db.Close() - return nil, fmt.Errorf("migrate profile columns: %w", err) - } - st := &Store{db: db, dialect: dialectSQLite} - st.initSessionStore(sessionCleanup) - return st, nil -} - func (s *Store) Close() error { if s.sessionStopper != nil { s.sessionStopper.StopCleanup() @@ -104,22 +66,21 @@ func (s *Store) Close() error { return s.db.Close() } -func (s *Store) CreateUser(ctx context.Context, username, passwordHash string, asAdmin bool) (*User, error) { - username = NormalizeUsername(username) - role := "user" - if asAdmin { - role = "admin" +func (s *Store) CreateUser(ctx context.Context, nu NewUser) (*User, error) { + if nu.Role != RoleUser && nu.Role != RoleAdmin { + return nil, fmt.Errorf("invalid role") } + username := NormalizeUsername(nu.Username) u := &User{ ID: uuid.NewString(), Username: username, Name: username, - Role: role, - PasswordHash: passwordHash, + Role: nu.Role, + 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 (?, ?, ?, ?, ?, '', '', ?)`), - u.ID, u.Username, u.Name, u.PasswordHash, u.Role, u.CreatedAt) + u.ID, u.Username, u.Name, u.PasswordHash, string(u.Role), u.CreatedAt) if err != nil { return nil, err } @@ -128,7 +89,7 @@ func (s *Store) CreateUser(ctx context.Context, username, passwordHash string, a 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 = ?`), "admin").Scan(&n) + err := s.db.QueryRowContext(ctx, s.q(`SELECT COUNT(*) FROM users WHERE role = ?`), string(RoleAdmin)).Scan(&n) return n, err } @@ -141,16 +102,18 @@ func (s *Store) ListUsers(ctx context.Context) ([]User, error) { var out []User for rows.Next() { var u User - if err := rows.Scan(&u.ID, &u.Username, &u.Name, &u.Role, &u.AvatarURL, &u.State, &u.CreatedAt); err != nil { + var role string + if err := rows.Scan(&u.ID, &u.Username, &u.Name, &role, &u.AvatarURL, &u.State, &u.CreatedAt); err != nil { return nil, err } + u.Role = Role(role) out = append(out, u) } return out, rows.Err() } -func (s *Store) SetRole(ctx context.Context, userID, role string) error { - if role != "user" && role != "admin" { +func (s *Store) SetRole(ctx context.Context, userID string, role Role) error { + if role != RoleUser && role != RoleAdmin { return fmt.Errorf("invalid role") } tx, err := s.db.BeginTx(ctx, nil) @@ -164,16 +127,16 @@ func (s *Store) SetRole(ctx context.Context, userID, role string) error { if err != nil { return err } - if current == "admin" && role == "user" { + if Role(current) == RoleAdmin && role == RoleUser { var n int - if err := tx.QueryRowContext(ctx, s.q(`SELECT COUNT(*) FROM users WHERE role = ?`), "admin").Scan(&n); err != nil { + if err := tx.QueryRowContext(ctx, s.q(`SELECT COUNT(*) FROM users WHERE role = ?`), 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 = ?`), role, userID) + res, err := tx.ExecContext(ctx, s.q(`UPDATE users SET role = ? WHERE id = ?`), string(role), userID) if err != nil { return err } @@ -197,15 +160,17 @@ func (s *Store) UserByUsername(ctx context.Context, username string) (*User, err func scanUser(row *sql.Row, withSecrets bool) (*User, error) { var u User + var role string var err error if withSecrets { - err = row.Scan(&u.ID, &u.Username, &u.Name, &u.Role, &u.AvatarURL, &u.State, &u.CreatedAt, &u.PasswordHash) + err = row.Scan(&u.ID, &u.Username, &u.Name, &role, &u.AvatarURL, &u.State, &u.CreatedAt, &u.PasswordHash) } else { - err = row.Scan(&u.ID, &u.Username, &u.Name, &u.Role, &u.AvatarURL, &u.State, &u.CreatedAt) + err = row.Scan(&u.ID, &u.Username, &u.Name, &role, &u.AvatarURL, &u.State, &u.CreatedAt) } if err != nil { return nil, err } + u.Role = Role(role) return &u, nil } diff --git a/internal/web/admin.go b/internal/web/admin.go index 2e955d3..e7e9e70 100644 --- a/internal/web/admin.go +++ b/internal/web/admin.go @@ -47,7 +47,7 @@ func (s *Server) handleAdminSetRole(w http.ResponseWriter, r *http.Request) { return } id := chi.URLParam(r, "id") - role := r.PostFormValue("role") + role := store.Role(r.PostFormValue("role")) err := s.store.SetRole(r.Context(), id, role) if errors.Is(err, store.ErrLastAdmin) { users, listErr := s.store.ListUsers(r.Context()) diff --git a/internal/web/auth.go b/internal/web/auth.go index 4bfcb00..65cc384 100644 --- a/internal/web/auth.go +++ b/internal/web/auth.go @@ -88,16 +88,22 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { http.Error(w, "could not save password", http.StatusInternalServerError) return } - asAdmin := false + role := store.RoleUser if s.cfg.AdminUsername != "" && store.NormalizeUsername(username) == store.NormalizeUsername(s.cfg.AdminUsername) { n, err := s.store.CountAdmins(r.Context()) if err != nil { http.Error(w, "could not create account", http.StatusInternalServerError) return } - asAdmin = n == 0 + if n == 0 { + role = store.RoleAdmin + } } - u, err := s.store.CreateUser(r.Context(), username, string(hash), asAdmin) + u, err := s.store.CreateUser(r.Context(), store.NewUser{ + Username: username, + PasswordHash: string(hash), + Role: role, + }) if err != nil { p.Error = "That username is taken." s.exec(w, "register", p) diff --git a/internal/web/memstore_test.go b/internal/web/memstore_test.go index 0eb62d4..8476ce5 100644 --- a/internal/web/memstore_test.go +++ b/internal/web/memstore_test.go @@ -18,11 +18,11 @@ import ( // memDB is an in-memory store.DB for tests. type memDB struct { mu sync.Mutex - users map[string]*store.User // id -> user - byName map[string]string // username -> id - questions map[string]*store.RankedQuestion // id -> question - votes map[string]int // userID|questionID -> value - answers map[string]*store.Answer // questionID -> answer + users map[string]*store.User // id -> user + byName map[string]string // username -> id + questions map[string]*store.RankedQuestion // id -> question + votes map[string]int // userID|questionID -> value + answers map[string]*store.Answer // questionID -> answer } func newMemDB() *memDB { @@ -39,23 +39,22 @@ func voteKey(userID, questionID string) string { return userID + "|" + questionID } -func (m *memDB) CreateUser(_ context.Context, username, passwordHash string, asAdmin bool) (*store.User, error) { +func (m *memDB) CreateUser(_ context.Context, nu store.NewUser) (*store.User, error) { m.mu.Lock() defer m.mu.Unlock() - username = store.NormalizeUsername(username) + username := store.NormalizeUsername(nu.Username) if _, ok := m.byName[username]; ok { return nil, fmt.Errorf("username taken") } - role := "user" - if asAdmin { - role = "admin" + if nu.Role != store.RoleUser && nu.Role != store.RoleAdmin { + return nil, fmt.Errorf("invalid role") } u := &store.User{ ID: uuid.NewString(), Username: username, Name: username, - Role: role, - PasswordHash: passwordHash, + Role: nu.Role, + PasswordHash: nu.PasswordHash, CreatedAt: time.Now().UTC().Format(time.RFC3339), } m.users[u.ID] = u @@ -92,7 +91,7 @@ func (m *memDB) CountAdmins(_ context.Context) (int, error) { defer m.mu.Unlock() n := 0 for _, u := range m.users { - if u.Role == "admin" { + if u.Role == store.RoleAdmin { n++ } } @@ -114,8 +113,8 @@ func (m *memDB) ListUsers(_ context.Context) ([]store.User, error) { return out, nil } -func (m *memDB) SetRole(_ context.Context, userID, role string) error { - if role != "user" && role != "admin" { +func (m *memDB) SetRole(_ context.Context, userID string, role store.Role) error { + if role != store.RoleUser && role != store.RoleAdmin { return fmt.Errorf("invalid role") } m.mu.Lock() @@ -124,10 +123,10 @@ func (m *memDB) SetRole(_ context.Context, userID, role string) error { if !ok { return sql.ErrNoRows } - if u.Role == "admin" && role == "user" { + if u.Role == store.RoleAdmin && role == store.RoleUser { n := 0 for _, x := range m.users { - if x.Role == "admin" { + if x.Role == store.RoleAdmin { n++ } }