Address PR review: graceful shutdown, Role/NewUser, drop SQLite.
This commit is contained in:
+3
-4
@@ -1,14 +1,13 @@
|
|||||||
# Local listen address (ignored when PORT is set, e.g. on App Platform)
|
# Local listen address (ignored when PORT is set, e.g. on App Platform)
|
||||||
LISTEN=:8080
|
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.
|
# Optional: first matching registrant becomes admin only if no admin exists yet.
|
||||||
# Later promote/demote via /admin/users (admins only).
|
# Later promote/demote via /admin/users (admins only).
|
||||||
ADMIN_USERNAME=yourusername
|
ADMIN_USERNAME=yourusername
|
||||||
# Set to 1 when serving over HTTPS
|
# Set to 1 when serving over HTTPS
|
||||||
SECURE_COOKIE=0
|
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.
|
# DigitalOcean Spaces (profile avatars). Leave unset to disable uploads.
|
||||||
# SPACES_KEY=
|
# SPACES_KEY=
|
||||||
# SPACES_SECRET=
|
# SPACES_SECRET=
|
||||||
|
|||||||
+49
-14
@@ -1,10 +1,15 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
|
|
||||||
@@ -17,29 +22,25 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
_ = godotenv.Load()
|
_ = godotenv.Load()
|
||||||
listen := listenAddr()
|
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 {
|
if err != nil {
|
||||||
log.Fatalf("database: %v", err)
|
log.Fatalf("database: %v", err)
|
||||||
}
|
}
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
if os.Getenv("DATABASE_URL") != "" {
|
|
||||||
log.Printf("database: postgres")
|
log.Printf("database: postgres")
|
||||||
} else {
|
|
||||||
log.Printf("database: sqlite")
|
uploader := spacesUploader()
|
||||||
}
|
|
||||||
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"),
|
|
||||||
})
|
|
||||||
if uploader.Enabled() {
|
if uploader.Enabled() {
|
||||||
log.Printf("avatars: digitalocean spaces")
|
log.Printf("avatars: digitalocean spaces")
|
||||||
} else {
|
} else {
|
||||||
log.Printf("avatars: uploads disabled (set SPACES_* to enable)")
|
log.Printf("avatars: uploads disabled (set SPACES_* to enable)")
|
||||||
}
|
}
|
||||||
|
|
||||||
srv, err := web.New(st, st.SessionStore(), plumber.TemplateFS, plumber.StaticFS, web.Config{
|
srv, err := web.New(st, st.SessionStore(), plumber.TemplateFS, plumber.StaticFS, web.Config{
|
||||||
AdminUsername: os.Getenv("ADMIN_USERNAME"),
|
AdminUsername: os.Getenv("ADMIN_USERNAME"),
|
||||||
SecureCookie: os.Getenv("SECURE_COOKIE") == "1",
|
SecureCookie: os.Getenv("SECURE_COOKIE") == "1",
|
||||||
@@ -48,10 +49,44 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("server: %v", err)
|
log.Fatalf("server: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
httpSrv := &http.Server{Addr: listen, Handler: srv.Handler()}
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
log.Printf("listening on %s", listen)
|
log.Printf("listening on %s", listen)
|
||||||
if err := http.ListenAndServe(listen, srv.Handler()); err != nil {
|
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)
|
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.
|
// listenAddr prefers PORT (App Platform / PaaS), then LISTEN, then :8080.
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ require (
|
|||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
golang.org/x/crypto v0.55.0
|
golang.org/x/crypto v0.55.0
|
||||||
modernc.org/sqlite v1.57.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/presigned-url v1.13.38 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.39 // 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/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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // 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/sync v0.22.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
|
||||||
golang.org/x/text v0.41.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
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
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/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 h1:TmtCFbH+Aw0AixwyttznSMQDgbR5Yed/Gg6S8Funrhc=
|
||||||
github.com/lib/pq v1.4.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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/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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
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=
|
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 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
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 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
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 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
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/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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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=
|
|
||||||
|
|||||||
+11
-11
@@ -21,12 +21,6 @@ type Uploader interface {
|
|||||||
// Disabled is a no-op uploader used when Spaces is not configured.
|
// Disabled is a no-op uploader used when Spaces is not configured.
|
||||||
type Disabled struct{}
|
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.
|
// SpacesConfig holds DigitalOcean Spaces settings.
|
||||||
type SpacesConfig struct {
|
type SpacesConfig struct {
|
||||||
Key string
|
Key string
|
||||||
@@ -37,6 +31,17 @@ type SpacesConfig struct {
|
|||||||
CDNBase string // optional public base URL without trailing slash
|
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.
|
// NewSpaces returns an Uploader when required env is present; otherwise Disabled.
|
||||||
func NewSpaces(cfg SpacesConfig) Uploader {
|
func NewSpaces(cfg SpacesConfig) Uploader {
|
||||||
cfg.Key = strings.TrimSpace(cfg.Key)
|
cfg.Key = strings.TrimSpace(cfg.Key)
|
||||||
@@ -56,11 +61,6 @@ func NewSpaces(cfg SpacesConfig) Uploader {
|
|||||||
return &spaces{client: client, cfg: cfg}
|
return &spaces{client: client, cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
type spaces struct {
|
|
||||||
client *s3.Client
|
|
||||||
cfg SpacesConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *spaces) Enabled() bool { return true }
|
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) {
|
func (s *spaces) Upload(ctx context.Context, key string, body io.Reader, contentType string, size int64) (string, error) {
|
||||||
|
|||||||
+17
-2
@@ -8,15 +8,30 @@ import (
|
|||||||
// ErrLastAdmin is returned when demoting the only remaining admin.
|
// ErrLastAdmin is returned when demoting the only remaining admin.
|
||||||
var ErrLastAdmin = errors.New("cannot demote the last 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.
|
// DB is the persistence API used by the web layer.
|
||||||
// Named DB to avoid colliding with scs.Store.
|
// Named DB to avoid colliding with scs.Store.
|
||||||
type DB interface {
|
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)
|
UserByID(ctx context.Context, id string) (*User, error)
|
||||||
UserByUsername(ctx context.Context, username string) (*User, error)
|
UserByUsername(ctx context.Context, username string) (*User, error)
|
||||||
CountAdmins(ctx context.Context) (int, error)
|
CountAdmins(ctx context.Context) (int, error)
|
||||||
ListUsers(ctx context.Context) ([]User, 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)
|
CreateQuestion(ctx context.Context, authorID, title, body, city string) (*RankedQuestion, error)
|
||||||
ListHunt(ctx context.Context, huntDate, viewerID string) ([]RankedQuestion, error)
|
ListHunt(ctx context.Context, huntDate, viewerID string) ([]RankedQuestion, error)
|
||||||
GetQuestion(ctx context.Context, id, viewerID string) (*RankedQuestion, error)
|
GetQuestion(ctx context.Context, id, viewerID string) (*RankedQuestion, error)
|
||||||
|
|||||||
@@ -3,24 +3,14 @@ package store
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"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"}
|
cols := []string{"avatar_url", "state"}
|
||||||
for _, col := range cols {
|
for _, col := range cols {
|
||||||
var stmt string
|
stmt := fmt.Sprintf(`ALTER TABLE users ADD COLUMN IF NOT EXISTS %s TEXT NOT NULL DEFAULT ''`, col)
|
||||||
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)
|
|
||||||
}
|
|
||||||
if _, err := db.Exec(stmt); err != nil {
|
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)
|
return fmt.Errorf("add column %s: %w", col, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,7 @@ import (
|
|||||||
_ "github.com/jackc/pgx/v5/stdlib"
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
// rebind converts ? placeholders to Postgres $1, $2, ... form.
|
||||||
dialectSQLite = "sqlite"
|
|
||||||
dialectPostgres = "postgres"
|
|
||||||
)
|
|
||||||
|
|
||||||
func rebind(query string) string {
|
func rebind(query string) string {
|
||||||
n := 0
|
n := 0
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
@@ -31,13 +27,12 @@ func rebind(query string) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// q rebinds SQL placeholders for Postgres.
|
||||||
func (s *Store) q(query string) string {
|
func (s *Store) q(query string) string {
|
||||||
if s.dialect == dialectPostgres {
|
|
||||||
return rebind(query)
|
return rebind(query)
|
||||||
}
|
|
||||||
return query
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applySchema runs semicolon-separated DDL statements, skipping PRAGMA lines.
|
||||||
func applySchema(db *sql.DB, schema string) error {
|
func applySchema(db *sql.DB, schema string) error {
|
||||||
for _, stmt := range strings.Split(schema, ";") {
|
for _, stmt := range strings.Split(schema, ";") {
|
||||||
stmt = strings.TrimSpace(stmt)
|
stmt = strings.TrimSpace(stmt)
|
||||||
@@ -55,6 +50,7 @@ func applySchema(db *sql.DB, schema string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// postgresDSN normalizes DATABASE_URL for pgx (sslmode default, strip unsupported params).
|
||||||
func postgresDSN(raw string) (string, error) {
|
func postgresDSN(raw string) (string, error) {
|
||||||
u, err := url.Parse(raw)
|
u, err := url.Parse(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -77,16 +73,8 @@ func postgresDSN(raw string) (string, error) {
|
|||||||
return u.String(), nil
|
return u.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenPostgres connects to Postgres, applies schema/migrations, and starts session cleanup.
|
||||||
func OpenPostgres(databaseURL, schema string) (*Store, error) {
|
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)
|
dsn, err := postgresDSN(databaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -105,26 +93,15 @@ func openPostgres(databaseURL, schema string, sessionCleanup time.Duration) (*St
|
|||||||
_ = db.Close()
|
_ = db.Close()
|
||||||
return nil, fmt.Errorf("apply schema: %w", err)
|
return nil, fmt.Errorf("apply schema: %w", err)
|
||||||
}
|
}
|
||||||
if err := applySessionsSchema(db, dialectPostgres); err != nil {
|
if err := applySessionsSchema(db); err != nil {
|
||||||
_ = db.Close()
|
_ = db.Close()
|
||||||
return nil, fmt.Errorf("apply sessions schema: %w", err)
|
return nil, fmt.Errorf("apply sessions schema: %w", err)
|
||||||
}
|
}
|
||||||
if err := migrateUserProfileColumns(db, dialectPostgres); err != nil {
|
if err := migrateUserProfileColumns(db); err != nil {
|
||||||
_ = db.Close()
|
_ = db.Close()
|
||||||
return nil, fmt.Errorf("migrate profile columns: %w", err)
|
return nil, fmt.Errorf("migrate profile columns: %w", err)
|
||||||
}
|
}
|
||||||
st := &Store{db: db, dialect: dialectPostgres}
|
st := &Store{db: db}
|
||||||
st.initSessionStore(sessionCleanup)
|
st.initSessionStore(5 * time.Minute)
|
||||||
return st, nil
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-119
@@ -2,22 +2,12 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"log"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/alexedwards/scs/postgresstore"
|
"github.com/alexedwards/scs/postgresstore"
|
||||||
"github.com/alexedwards/scs/v2"
|
"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 = `
|
const sessionsSchemaPostgres = `
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
token TEXT PRIMARY KEY,
|
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);
|
CREATE INDEX IF NOT EXISTS sessions_expiry_idx ON sessions (expiry);
|
||||||
`
|
`
|
||||||
|
|
||||||
func applySessionsSchema(db *sql.DB, dialect string) error {
|
// applySessionsSchema creates the scs sessions table if missing.
|
||||||
schema := sessionsSchemaSQLite
|
func applySessionsSchema(db *sql.DB) error {
|
||||||
if dialect == dialectPostgres {
|
return applySchema(db, sessionsSchemaPostgres)
|
||||||
schema = sessionsSchemaPostgres
|
|
||||||
}
|
|
||||||
return applySchema(db, schema)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type sessionStopper interface {
|
type sessionStopper interface {
|
||||||
@@ -45,110 +32,7 @@ func (s *Store) SessionStore() scs.Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) initSessionStore(cleanupInterval time.Duration) {
|
func (s *Store) initSessionStore(cleanupInterval time.Duration) {
|
||||||
switch s.dialect {
|
|
||||||
case dialectPostgres:
|
|
||||||
ps := postgresstore.NewWithCleanupInterval(s.db, cleanupInterval)
|
ps := postgresstore.NewWithCleanupInterval(s.db, cleanupInterval)
|
||||||
s.sessionStore = ps
|
s.sessionStore = ps
|
||||||
s.sessionStopper = ps
|
s.sessionStopper = ps
|
||||||
default:
|
|
||||||
ss := newSQLiteSessionStore(s.db, cleanupInterval)
|
|
||||||
s.sessionStore = ss
|
|
||||||
s.sessionStopper = ss
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
|
||||||
)
|
|
||||||
|
|||||||
+22
-57
@@ -9,14 +9,12 @@ import (
|
|||||||
|
|
||||||
"github.com/alexedwards/scs/v2"
|
"github.com/alexedwards/scs/v2"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
_ "modernc.org/sqlite"
|
|
||||||
|
|
||||||
"plumber/internal/pacific"
|
"plumber/internal/pacific"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Store struct {
|
type Store struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
dialect string
|
|
||||||
sessionStore scs.Store
|
sessionStore scs.Store
|
||||||
sessionStopper sessionStopper
|
sessionStopper sessionStopper
|
||||||
}
|
}
|
||||||
@@ -25,7 +23,7 @@ type User struct {
|
|||||||
ID string
|
ID string
|
||||||
Username string
|
Username string
|
||||||
Name string
|
Name string
|
||||||
Role string
|
Role Role
|
||||||
AvatarURL string
|
AvatarURL string
|
||||||
State string
|
State string
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
@@ -33,7 +31,7 @@ type User struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *User) Admin() bool {
|
func (u *User) Admin() bool {
|
||||||
return u != nil && u.Role == "admin"
|
return u != nil && u.Role == RoleAdmin
|
||||||
}
|
}
|
||||||
|
|
||||||
type RankedQuestion struct {
|
type RankedQuestion struct {
|
||||||
@@ -60,42 +58,6 @@ type Answer struct {
|
|||||||
UpdatedAt string
|
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 {
|
func (s *Store) Close() error {
|
||||||
if s.sessionStopper != nil {
|
if s.sessionStopper != nil {
|
||||||
s.sessionStopper.StopCleanup()
|
s.sessionStopper.StopCleanup()
|
||||||
@@ -104,22 +66,21 @@ func (s *Store) Close() error {
|
|||||||
return s.db.Close()
|
return s.db.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) CreateUser(ctx context.Context, username, passwordHash string, asAdmin bool) (*User, error) {
|
func (s *Store) CreateUser(ctx context.Context, nu NewUser) (*User, error) {
|
||||||
username = NormalizeUsername(username)
|
if nu.Role != RoleUser && nu.Role != RoleAdmin {
|
||||||
role := "user"
|
return nil, fmt.Errorf("invalid role")
|
||||||
if asAdmin {
|
|
||||||
role = "admin"
|
|
||||||
}
|
}
|
||||||
|
username := NormalizeUsername(nu.Username)
|
||||||
u := &User{
|
u := &User{
|
||||||
ID: uuid.NewString(),
|
ID: uuid.NewString(),
|
||||||
Username: username,
|
Username: username,
|
||||||
Name: username,
|
Name: username,
|
||||||
Role: role,
|
Role: nu.Role,
|
||||||
PasswordHash: passwordHash,
|
PasswordHash: nu.PasswordHash,
|
||||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
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, 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 {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
func (s *Store) CountAdmins(ctx context.Context) (int, error) {
|
||||||
var n int
|
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
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,16 +102,18 @@ func (s *Store) ListUsers(ctx context.Context) ([]User, error) {
|
|||||||
var out []User
|
var out []User
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var u User
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
u.Role = Role(role)
|
||||||
out = append(out, u)
|
out = append(out, u)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) SetRole(ctx context.Context, userID, role string) error {
|
func (s *Store) SetRole(ctx context.Context, userID string, role Role) error {
|
||||||
if role != "user" && role != "admin" {
|
if role != RoleUser && role != RoleAdmin {
|
||||||
return fmt.Errorf("invalid role")
|
return fmt.Errorf("invalid role")
|
||||||
}
|
}
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if current == "admin" && role == "user" {
|
if Role(current) == RoleAdmin && role == RoleUser {
|
||||||
var n int
|
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
|
return err
|
||||||
}
|
}
|
||||||
if n <= 1 {
|
if n <= 1 {
|
||||||
return ErrLastAdmin
|
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 {
|
if err != nil {
|
||||||
return err
|
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) {
|
func scanUser(row *sql.Row, withSecrets bool) (*User, error) {
|
||||||
var u User
|
var u User
|
||||||
|
var role string
|
||||||
var err error
|
var err error
|
||||||
if withSecrets {
|
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 {
|
} 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
u.Role = Role(role)
|
||||||
return &u, nil
|
return &u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func (s *Server) handleAdminSetRole(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
role := r.PostFormValue("role")
|
role := store.Role(r.PostFormValue("role"))
|
||||||
err := s.store.SetRole(r.Context(), id, role)
|
err := s.store.SetRole(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())
|
||||||
|
|||||||
@@ -88,16 +88,22 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "could not save password", http.StatusInternalServerError)
|
http.Error(w, "could not save password", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
asAdmin := false
|
role := store.RoleUser
|
||||||
if s.cfg.AdminUsername != "" && store.NormalizeUsername(username) == store.NormalizeUsername(s.cfg.AdminUsername) {
|
if s.cfg.AdminUsername != "" && store.NormalizeUsername(username) == store.NormalizeUsername(s.cfg.AdminUsername) {
|
||||||
n, err := s.store.CountAdmins(r.Context())
|
n, err := s.store.CountAdmins(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "could not create account", http.StatusInternalServerError)
|
http.Error(w, "could not create account", http.StatusInternalServerError)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
p.Error = "That username is taken."
|
p.Error = "That username is taken."
|
||||||
s.exec(w, "register", p)
|
s.exec(w, "register", p)
|
||||||
|
|||||||
@@ -39,23 +39,22 @@ func voteKey(userID, questionID string) string {
|
|||||||
return userID + "|" + questionID
|
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()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
username = store.NormalizeUsername(username)
|
username := store.NormalizeUsername(nu.Username)
|
||||||
if _, ok := m.byName[username]; ok {
|
if _, ok := m.byName[username]; ok {
|
||||||
return nil, fmt.Errorf("username taken")
|
return nil, fmt.Errorf("username taken")
|
||||||
}
|
}
|
||||||
role := "user"
|
if nu.Role != store.RoleUser && nu.Role != store.RoleAdmin {
|
||||||
if asAdmin {
|
return nil, fmt.Errorf("invalid role")
|
||||||
role = "admin"
|
|
||||||
}
|
}
|
||||||
u := &store.User{
|
u := &store.User{
|
||||||
ID: uuid.NewString(),
|
ID: uuid.NewString(),
|
||||||
Username: username,
|
Username: username,
|
||||||
Name: username,
|
Name: username,
|
||||||
Role: role,
|
Role: nu.Role,
|
||||||
PasswordHash: passwordHash,
|
PasswordHash: nu.PasswordHash,
|
||||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
m.users[u.ID] = u
|
m.users[u.ID] = u
|
||||||
@@ -92,7 +91,7 @@ func (m *memDB) CountAdmins(_ context.Context) (int, error) {
|
|||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
n := 0
|
n := 0
|
||||||
for _, u := range m.users {
|
for _, u := range m.users {
|
||||||
if u.Role == "admin" {
|
if u.Role == store.RoleAdmin {
|
||||||
n++
|
n++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,8 +113,8 @@ func (m *memDB) ListUsers(_ context.Context) ([]store.User, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *memDB) SetRole(_ context.Context, userID, role string) error {
|
func (m *memDB) SetRole(_ context.Context, userID string, role store.Role) error {
|
||||||
if role != "user" && role != "admin" {
|
if role != store.RoleUser && role != store.RoleAdmin {
|
||||||
return fmt.Errorf("invalid role")
|
return fmt.Errorf("invalid role")
|
||||||
}
|
}
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -124,10 +123,10 @@ func (m *memDB) SetRole(_ context.Context, userID, role string) error {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return sql.ErrNoRows
|
return sql.ErrNoRows
|
||||||
}
|
}
|
||||||
if u.Role == "admin" && role == "user" {
|
if u.Role == store.RoleAdmin && role == store.RoleUser {
|
||||||
n := 0
|
n := 0
|
||||||
for _, x := range m.users {
|
for _, x := range m.users {
|
||||||
if x.Role == "admin" {
|
if x.Role == store.RoleAdmin {
|
||||||
n++
|
n++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user