diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..42b1ad57 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +.PHONY: generate test test-short lint build + +SQLC_VERSION := 1.27.0 + +generate: + docker run --rm -v "$(CURDIR):/src" -w /src sqlc/sqlc:$(SQLC_VERSION) generate + +test: + go test -race ./... + +test-short: + go test -short -race ./... + +lint: + golangci-lint run ./... + +build: + go build -o bin/minstrel ./cmd/minstrel diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index b00ccbd8..226b43cd 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -11,6 +11,7 @@ import ( "syscall" "time" + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/config" "git.fabledsword.com/bvandeusen/minstrel/internal/db" "git.fabledsword.com/bvandeusen/minstrel/internal/logging" @@ -57,6 +58,10 @@ func run() error { } defer pool.Close() + if err := auth.Bootstrap(ctx, pool, cfg.Auth.AdminBootstrap, logger); err != nil { + return fmt.Errorf("bootstrap admin: %w", err) + } + srv := server.New(logger) httpServer := &http.Server{ Addr: cfg.Server.Address, diff --git a/go.mod b/go.mod index 3b7a78ff..aee4e92a 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/go-chi/chi/v5 v5.2.5 github.com/golang-migrate/migrate/v4 v4.18.2 github.com/jackc/pgx/v5 v5.7.4 + golang.org/x/crypto v0.35.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -16,10 +17,8 @@ require ( 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/kr/text v0.2.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect go.uber.org/atomic v1.7.0 // indirect - golang.org/x/crypto v0.35.0 // indirect golang.org/x/sync v0.11.0 // indirect golang.org/x/text v0.22.0 // indirect ) diff --git a/go.sum b/go.sum index 2738f885..27153f9a 100644 --- a/go.sum +++ b/go.sum @@ -2,25 +2,15 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25 github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= -github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= -github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= -github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8= -github.com/dhui/dktest v0.4.5 h1:uUfYBIVREmj/Rw6MvgmqNAYzTiKOHJak+enB5Di73MM= -github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= -github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4= -github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= -github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -30,18 +20,13 @@ github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSw github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8= github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk= -github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs= -github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY= -github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= -github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -55,10 +40,6 @@ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7Ulw github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= -github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= -github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= -github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= -github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= @@ -81,46 +62,31 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/auth/bootstrap.go b/internal/auth/bootstrap.go new file mode 100644 index 00000000..00c5b02e --- /dev/null +++ b/internal/auth/bootstrap.go @@ -0,0 +1,85 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "log/slog" + "os" + + "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/crypto/bcrypt" + + "git.fabledsword.com/bvandeusen/minstrel/internal/config" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// Bootstrap creates the initial admin user when the users table is empty AND +// the config supplies a bootstrap username. A blank password means "generate a +// random one and print it once on stderr" — suitable for first-run setups +// where the operator is watching the logs. +func Bootstrap(ctx context.Context, pool *pgxpool.Pool, cfg config.AdminBootstrapConfig, logger *slog.Logger) error { + if cfg.Username == "" { + return nil + } + + q := dbq.New(pool) + count, err := q.CountUsers(ctx) + if err != nil { + return fmt.Errorf("auth: count users: %w", err) + } + if count > 0 { + return nil + } + + password := cfg.Password + passwordGenerated := false + if password == "" { + p, err := randomToken(18) + if err != nil { + return fmt.Errorf("auth: generate password: %w", err) + } + password = p + passwordGenerated = true + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("auth: hash password: %w", err) + } + + apiToken, err := randomToken(32) + if err != nil { + return fmt.Errorf("auth: generate api token: %w", err) + } + + user, err := q.CreateUser(ctx, dbq.CreateUserParams{ + Username: cfg.Username, + PasswordHash: string(hash), + ApiToken: apiToken, + IsAdmin: true, + }) + if err != nil { + return fmt.Errorf("auth: insert admin: %w", err) + } + + logger.Info("admin bootstrapped", "username", cfg.Username, "user_id", user.ID.String()) + if passwordGenerated { + fmt.Fprintf(os.Stderr, "\n--- minstrel admin bootstrap (shown once) ---\nusername: %s\npassword: %s\napi_token: %s\n---\n\n", + cfg.Username, password, apiToken) + } else { + fmt.Fprintf(os.Stderr, "\n--- minstrel admin api token (shown once) ---\nusername: %s\napi_token: %s\n---\n\n", + cfg.Username, apiToken) + } + return nil +} + +// randomToken returns a URL-safe base64 string backed by n random bytes. +func randomToken(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/internal/auth/bootstrap_test.go b/internal/auth/bootstrap_test.go new file mode 100644 index 00000000..7fe6ec5b --- /dev/null +++ b/internal/auth/bootstrap_test.go @@ -0,0 +1,95 @@ +package auth + +import ( + "context" + "io" + "log/slog" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/crypto/bcrypt" + + "git.fabledsword.com/bvandeusen/minstrel/internal/config" + "git.fabledsword.com/bvandeusen/minstrel/internal/db" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// TestBootstrap_Integration exercises the admin-bootstrap flow against a real +// Postgres. Gated on MINSTREL_TEST_DATABASE_URL, skipped in -short mode. +func TestBootstrap_Integration(t *testing.T) { + if testing.Short() { + t.Skip("skipping bootstrap integration in -short mode") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set; skipping") + } + ctx := context.Background() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + if err := db.Migrate(dsn, logger); err != nil { + t.Fatalf("Migrate: %v", err) + } + + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(pool.Close) + + // Start from a known-empty users table. Other tests that insert users + // must run in isolation; a shared compose pg is fine for solo dev. + if _, err := pool.Exec(ctx, "TRUNCATE users RESTART IDENTITY CASCADE"); err != nil { + t.Fatalf("truncate: %v", err) + } + + cfg := config.AdminBootstrapConfig{Username: "admin", Password: "hunter2"} + if err := Bootstrap(ctx, pool, cfg, logger); err != nil { + t.Fatalf("Bootstrap: %v", err) + } + + q := dbq.New(pool) + user, err := q.GetUserByUsername(ctx, "admin") + if err != nil { + t.Fatalf("GetUserByUsername: %v", err) + } + if !user.IsAdmin { + t.Error("bootstrapped user not marked admin") + } + if user.ApiToken == "" { + t.Error("api_token empty") + } + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte("hunter2")); err != nil { + t.Errorf("bcrypt compare: %v", err) + } + + byToken, err := q.GetUserByAPIToken(ctx, user.ApiToken) + if err != nil { + t.Fatalf("GetUserByAPIToken: %v", err) + } + if byToken.Username != "admin" { + t.Errorf("token lookup returned %q", byToken.Username) + } + + // Second call must be a no-op because users table is non-empty. + if err := Bootstrap(ctx, pool, cfg, logger); err != nil { + t.Fatalf("Bootstrap (second call): %v", err) + } + count, err := q.CountUsers(ctx) + if err != nil { + t.Fatalf("CountUsers: %v", err) + } + if count != 1 { + t.Errorf("expected exactly 1 user after idempotent bootstrap, got %d", count) + } +} + +// TestBootstrap_NoUsernameSkips verifies the no-config path without needing +// a database connection. +func TestBootstrap_NoUsernameSkips(t *testing.T) { + err := Bootstrap(context.Background(), nil, config.AdminBootstrapConfig{}, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Errorf("empty config should return nil, got %v", err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 4f69369e..2accc8c3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,6 +12,7 @@ type Config struct { Server ServerConfig `yaml:"server"` Database DatabaseConfig `yaml:"database"` Log LogConfig `yaml:"log"` + Auth AuthConfig `yaml:"auth"` } type ServerConfig struct { @@ -27,6 +28,18 @@ type LogConfig struct { Format string `yaml:"format"` } +type AuthConfig struct { + AdminBootstrap AdminBootstrapConfig `yaml:"admin_bootstrap"` +} + +// AdminBootstrapConfig drives the one-shot admin creation on an empty users +// table. Username is required to enable the flow; leaving Password blank asks +// the server to generate a random one and log it to stderr. +type AdminBootstrapConfig struct { + Username string `yaml:"username"` + Password string `yaml:"password"` +} + func Default() Config { return Config{ Server: ServerConfig{Address: ":4533"}, @@ -64,4 +77,10 @@ func applyEnv(cfg *Config) { if v, ok := os.LookupEnv("SMARTMUSIC_LOG_FORMAT"); ok { cfg.Log.Format = strings.ToLower(v) } + if v, ok := os.LookupEnv("SMARTMUSIC_AUTH_ADMIN_USERNAME"); ok { + cfg.Auth.AdminBootstrap.Username = v + } + if v, ok := os.LookupEnv("SMARTMUSIC_AUTH_ADMIN_PASSWORD"); ok { + cfg.Auth.AdminBootstrap.Password = v + } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2466f545..d36ef4c9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -78,3 +78,19 @@ func TestEnvOverrides(t *testing.T) { t.Errorf("log.format = %q, want lowercased json", cfg.Log.Format) } } + +func TestAdminBootstrapEnvOverrides(t *testing.T) { + t.Setenv("SMARTMUSIC_AUTH_ADMIN_USERNAME", "root") + t.Setenv("SMARTMUSIC_AUTH_ADMIN_PASSWORD", "sekret") + + cfg, err := Load("") + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Auth.AdminBootstrap.Username != "root" { + t.Errorf("admin username = %q", cfg.Auth.AdminBootstrap.Username) + } + if cfg.Auth.AdminBootstrap.Password != "sekret" { + t.Errorf("admin password = %q", cfg.Auth.AdminBootstrap.Password) + } +} diff --git a/internal/db/dbq/albums.sql.go b/internal/db/dbq/albums.sql.go new file mode 100644 index 00000000..f0c1b3f2 --- /dev/null +++ b/internal/db/dbq/albums.sql.go @@ -0,0 +1,114 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: albums.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const getAlbumByID = `-- name: GetAlbumByID :one +SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at FROM albums WHERE id = $1 +` + +func (q *Queries) GetAlbumByID(ctx context.Context, id pgtype.UUID) (Album, error) { + row := q.db.QueryRow(ctx, getAlbumByID, id) + var i Album + err := row.Scan( + &i.ID, + &i.Title, + &i.SortTitle, + &i.ArtistID, + &i.ReleaseDate, + &i.Mbid, + &i.CoverArtPath, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listAlbumsByArtist = `-- name: ListAlbumsByArtist :many +SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at FROM albums WHERE artist_id = $1 ORDER BY release_date NULLS LAST, sort_title +` + +func (q *Queries) ListAlbumsByArtist(ctx context.Context, artistID pgtype.UUID) ([]Album, error) { + rows, err := q.db.Query(ctx, listAlbumsByArtist, artistID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Album + for rows.Next() { + var i Album + if err := rows.Scan( + &i.ID, + &i.Title, + &i.SortTitle, + &i.ArtistID, + &i.ReleaseDate, + &i.Mbid, + &i.CoverArtPath, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertAlbum = `-- name: UpsertAlbum :one +INSERT INTO albums (title, sort_title, artist_id, release_date, mbid, cover_art_path) +VALUES ($1, $2, $3, $4, $5, $6) +ON CONFLICT (mbid) WHERE mbid IS NOT NULL +DO UPDATE SET + title = EXCLUDED.title, + sort_title = EXCLUDED.sort_title, + artist_id = EXCLUDED.artist_id, + release_date = EXCLUDED.release_date, + cover_art_path = EXCLUDED.cover_art_path, + updated_at = now() +RETURNING id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at +` + +type UpsertAlbumParams struct { + Title string + SortTitle string + ArtistID pgtype.UUID + ReleaseDate pgtype.Date + Mbid *string + CoverArtPath *string +} + +func (q *Queries) UpsertAlbum(ctx context.Context, arg UpsertAlbumParams) (Album, error) { + row := q.db.QueryRow(ctx, upsertAlbum, + arg.Title, + arg.SortTitle, + arg.ArtistID, + arg.ReleaseDate, + arg.Mbid, + arg.CoverArtPath, + ) + var i Album + err := row.Scan( + &i.ID, + &i.Title, + &i.SortTitle, + &i.ArtistID, + &i.ReleaseDate, + &i.Mbid, + &i.CoverArtPath, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/db/dbq/artists.sql.go b/internal/db/dbq/artists.sql.go new file mode 100644 index 00000000..17750aa1 --- /dev/null +++ b/internal/db/dbq/artists.sql.go @@ -0,0 +1,112 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: artists.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const getArtistByID = `-- name: GetArtistByID :one +SELECT id, name, sort_name, mbid, created_at, updated_at FROM artists WHERE id = $1 +` + +func (q *Queries) GetArtistByID(ctx context.Context, id pgtype.UUID) (Artist, error) { + row := q.db.QueryRow(ctx, getArtistByID, id) + var i Artist + err := row.Scan( + &i.ID, + &i.Name, + &i.SortName, + &i.Mbid, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getArtistByName = `-- name: GetArtistByName :one +SELECT id, name, sort_name, mbid, created_at, updated_at FROM artists WHERE name = $1 LIMIT 1 +` + +func (q *Queries) GetArtistByName(ctx context.Context, name string) (Artist, error) { + row := q.db.QueryRow(ctx, getArtistByName, name) + var i Artist + err := row.Scan( + &i.ID, + &i.Name, + &i.SortName, + &i.Mbid, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listArtists = `-- name: ListArtists :many +SELECT id, name, sort_name, mbid, created_at, updated_at FROM artists ORDER BY sort_name +` + +func (q *Queries) ListArtists(ctx context.Context) ([]Artist, error) { + rows, err := q.db.Query(ctx, listArtists) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Artist + for rows.Next() { + var i Artist + if err := rows.Scan( + &i.ID, + &i.Name, + &i.SortName, + &i.Mbid, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertArtist = `-- name: UpsertArtist :one +INSERT INTO artists (name, sort_name, mbid) +VALUES ($1, $2, $3) +ON CONFLICT (mbid) WHERE mbid IS NOT NULL +DO UPDATE SET + name = EXCLUDED.name, + sort_name = EXCLUDED.sort_name, + updated_at = now() +RETURNING id, name, sort_name, mbid, created_at, updated_at +` + +type UpsertArtistParams struct { + Name string + SortName string + Mbid *string +} + +// Insert or update by mbid when present; otherwise by (name, sort_name). +// Callers pass the canonical sort_name; scanner is responsible for derivation. +func (q *Queries) UpsertArtist(ctx context.Context, arg UpsertArtistParams) (Artist, error) { + row := q.db.QueryRow(ctx, upsertArtist, arg.Name, arg.SortName, arg.Mbid) + var i Artist + err := row.Scan( + &i.ID, + &i.Name, + &i.SortName, + &i.Mbid, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/db/dbq/db.go b/internal/db/dbq/db.go new file mode 100644 index 00000000..8650b40f --- /dev/null +++ b/internal/db/dbq/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go new file mode 100644 index 00000000..8936b9ea --- /dev/null +++ b/internal/db/dbq/models.go @@ -0,0 +1,57 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 + +package dbq + +import ( + "github.com/jackc/pgx/v5/pgtype" +) + +type Album struct { + ID pgtype.UUID + Title string + SortTitle string + ArtistID pgtype.UUID + ReleaseDate pgtype.Date + Mbid *string + CoverArtPath *string + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type Artist struct { + ID pgtype.UUID + Name string + SortName string + Mbid *string + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type Track struct { + ID pgtype.UUID + Title string + AlbumID pgtype.UUID + ArtistID pgtype.UUID + TrackNumber *int32 + DiscNumber *int32 + DurationMs int32 + FilePath string + FileSize int64 + FileFormat string + Bitrate *int32 + Mbid *string + Genre *string + AddedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type User struct { + ID pgtype.UUID + Username string + PasswordHash string + ApiToken string + IsAdmin bool + CreatedAt pgtype.Timestamptz +} diff --git a/internal/db/dbq/tracks.sql.go b/internal/db/dbq/tracks.sql.go new file mode 100644 index 00000000..6aa446ee --- /dev/null +++ b/internal/db/dbq/tracks.sql.go @@ -0,0 +1,179 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: tracks.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const getTrackByID = `-- name: GetTrackByID :one +SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks WHERE id = $1 +` + +func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, error) { + row := q.db.QueryRow(ctx, getTrackByID, id) + var i Track + err := row.Scan( + &i.ID, + &i.Title, + &i.AlbumID, + &i.ArtistID, + &i.TrackNumber, + &i.DiscNumber, + &i.DurationMs, + &i.FilePath, + &i.FileSize, + &i.FileFormat, + &i.Bitrate, + &i.Mbid, + &i.Genre, + &i.AddedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getTrackByPath = `-- name: GetTrackByPath :one +SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks WHERE file_path = $1 +` + +func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, error) { + row := q.db.QueryRow(ctx, getTrackByPath, filePath) + var i Track + err := row.Scan( + &i.ID, + &i.Title, + &i.AlbumID, + &i.ArtistID, + &i.TrackNumber, + &i.DiscNumber, + &i.DurationMs, + &i.FilePath, + &i.FileSize, + &i.FileFormat, + &i.Bitrate, + &i.Mbid, + &i.Genre, + &i.AddedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listTracksByAlbum = `-- name: ListTracksByAlbum :many +SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks WHERE album_id = $1 ORDER BY disc_number NULLS LAST, track_number NULLS LAST +` + +func (q *Queries) ListTracksByAlbum(ctx context.Context, albumID pgtype.UUID) ([]Track, error) { + rows, err := q.db.Query(ctx, listTracksByAlbum, albumID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Track + for rows.Next() { + var i Track + if err := rows.Scan( + &i.ID, + &i.Title, + &i.AlbumID, + &i.ArtistID, + &i.TrackNumber, + &i.DiscNumber, + &i.DurationMs, + &i.FilePath, + &i.FileSize, + &i.FileFormat, + &i.Bitrate, + &i.Mbid, + &i.Genre, + &i.AddedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertTrack = `-- name: UpsertTrack :one +INSERT INTO tracks ( + title, album_id, artist_id, track_number, disc_number, + duration_ms, file_path, file_size, file_format, bitrate, mbid, genre +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) +ON CONFLICT (file_path) DO UPDATE SET + title = EXCLUDED.title, + album_id = EXCLUDED.album_id, + artist_id = EXCLUDED.artist_id, + track_number = EXCLUDED.track_number, + disc_number = EXCLUDED.disc_number, + duration_ms = EXCLUDED.duration_ms, + file_size = EXCLUDED.file_size, + file_format = EXCLUDED.file_format, + bitrate = EXCLUDED.bitrate, + mbid = EXCLUDED.mbid, + genre = EXCLUDED.genre, + updated_at = now() +RETURNING id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at +` + +type UpsertTrackParams struct { + Title string + AlbumID pgtype.UUID + ArtistID pgtype.UUID + TrackNumber *int32 + DiscNumber *int32 + DurationMs int32 + FilePath string + FileSize int64 + FileFormat string + Bitrate *int32 + Mbid *string + Genre *string +} + +// file_path is the canonical identity for library scan; mbid is secondary. +func (q *Queries) UpsertTrack(ctx context.Context, arg UpsertTrackParams) (Track, error) { + row := q.db.QueryRow(ctx, upsertTrack, + arg.Title, + arg.AlbumID, + arg.ArtistID, + arg.TrackNumber, + arg.DiscNumber, + arg.DurationMs, + arg.FilePath, + arg.FileSize, + arg.FileFormat, + arg.Bitrate, + arg.Mbid, + arg.Genre, + ) + var i Track + err := row.Scan( + &i.ID, + &i.Title, + &i.AlbumID, + &i.ArtistID, + &i.TrackNumber, + &i.DiscNumber, + &i.DurationMs, + &i.FilePath, + &i.FileSize, + &i.FileFormat, + &i.Bitrate, + &i.Mbid, + &i.Genre, + &i.AddedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/db/dbq/users.sql.go b/internal/db/dbq/users.sql.go new file mode 100644 index 00000000..21a77f01 --- /dev/null +++ b/internal/db/dbq/users.sql.go @@ -0,0 +1,89 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: users.sql + +package dbq + +import ( + "context" +) + +const countUsers = `-- name: CountUsers :one +SELECT count(*) FROM users +` + +func (q *Queries) CountUsers(ctx context.Context) (int64, error) { + row := q.db.QueryRow(ctx, countUsers) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createUser = `-- name: CreateUser :one +INSERT INTO users (username, password_hash, api_token, is_admin) +VALUES ($1, $2, $3, $4) +RETURNING id, username, password_hash, api_token, is_admin, created_at +` + +type CreateUserParams struct { + Username string + PasswordHash string + ApiToken string + IsAdmin bool +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.db.QueryRow(ctx, createUser, + arg.Username, + arg.PasswordHash, + arg.ApiToken, + arg.IsAdmin, + ) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.ApiToken, + &i.IsAdmin, + &i.CreatedAt, + ) + return i, err +} + +const getUserByAPIToken = `-- name: GetUserByAPIToken :one +SELECT id, username, password_hash, api_token, is_admin, created_at FROM users WHERE api_token = $1 +` + +func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, error) { + row := q.db.QueryRow(ctx, getUserByAPIToken, apiToken) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.ApiToken, + &i.IsAdmin, + &i.CreatedAt, + ) + return i, err +} + +const getUserByUsername = `-- name: GetUserByUsername :one +SELECT id, username, password_hash, api_token, is_admin, created_at FROM users WHERE username = $1 +` + +func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) { + row := q.db.QueryRow(ctx, getUserByUsername, username) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.ApiToken, + &i.IsAdmin, + &i.CreatedAt, + ) + return i, err +} diff --git a/internal/db/migrations/0002_core_library.down.sql b/internal/db/migrations/0002_core_library.down.sql new file mode 100644 index 00000000..e6b4bf3e --- /dev/null +++ b/internal/db/migrations/0002_core_library.down.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS tracks; +DROP TABLE IF EXISTS albums; +DROP TABLE IF EXISTS artists; +-- pgcrypto left installed; other extensions may depend on it. diff --git a/internal/db/migrations/0002_core_library.up.sql b/internal/db/migrations/0002_core_library.up.sql new file mode 100644 index 00000000..f8e0cba8 --- /dev/null +++ b/internal/db/migrations/0002_core_library.up.sql @@ -0,0 +1,60 @@ +-- Core library schema per spec §5. Dual-like, session, scrobble, and +-- similarity tables land with their respective feature milestones. + +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +CREATE TABLE artists ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + sort_name text NOT NULL, + mbid text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX artists_mbid_unique ON artists (mbid) WHERE mbid IS NOT NULL; +CREATE INDEX artists_sort_name ON artists (sort_name); + +CREATE TABLE albums ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + title text NOT NULL, + sort_title text NOT NULL, + artist_id uuid NOT NULL REFERENCES artists (id) ON DELETE CASCADE, + release_date date, + mbid text, + cover_art_path text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX albums_mbid_unique ON albums (mbid) WHERE mbid IS NOT NULL; +CREATE INDEX albums_artist ON albums (artist_id); +CREATE INDEX albums_sort_title ON albums (sort_title); + +CREATE TABLE tracks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + title text NOT NULL, + album_id uuid NOT NULL REFERENCES albums (id) ON DELETE CASCADE, + artist_id uuid NOT NULL REFERENCES artists (id) ON DELETE CASCADE, + track_number integer, + disc_number integer, + duration_ms integer NOT NULL, + file_path text NOT NULL UNIQUE, + file_size bigint NOT NULL, + file_format text NOT NULL, + bitrate integer, + mbid text, + genre text, + added_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX tracks_mbid_unique ON tracks (mbid) WHERE mbid IS NOT NULL; +CREATE INDEX tracks_album ON tracks (album_id, disc_number, track_number); +CREATE INDEX tracks_artist ON tracks (artist_id); + +CREATE TABLE users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + username text NOT NULL UNIQUE, + password_hash text NOT NULL, + api_token text NOT NULL UNIQUE, + is_admin boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now() +); diff --git a/internal/db/queries/albums.sql b/internal/db/queries/albums.sql new file mode 100644 index 00000000..291aa311 --- /dev/null +++ b/internal/db/queries/albums.sql @@ -0,0 +1,18 @@ +-- name: UpsertAlbum :one +INSERT INTO albums (title, sort_title, artist_id, release_date, mbid, cover_art_path) +VALUES ($1, $2, $3, $4, $5, $6) +ON CONFLICT (mbid) WHERE mbid IS NOT NULL +DO UPDATE SET + title = EXCLUDED.title, + sort_title = EXCLUDED.sort_title, + artist_id = EXCLUDED.artist_id, + release_date = EXCLUDED.release_date, + cover_art_path = EXCLUDED.cover_art_path, + updated_at = now() +RETURNING *; + +-- name: GetAlbumByID :one +SELECT * FROM albums WHERE id = $1; + +-- name: ListAlbumsByArtist :many +SELECT * FROM albums WHERE artist_id = $1 ORDER BY release_date NULLS LAST, sort_title; diff --git a/internal/db/queries/artists.sql b/internal/db/queries/artists.sql new file mode 100644 index 00000000..fb1b6695 --- /dev/null +++ b/internal/db/queries/artists.sql @@ -0,0 +1,20 @@ +-- name: UpsertArtist :one +-- Insert or update by mbid when present; otherwise by (name, sort_name). +-- Callers pass the canonical sort_name; scanner is responsible for derivation. +INSERT INTO artists (name, sort_name, mbid) +VALUES ($1, $2, $3) +ON CONFLICT (mbid) WHERE mbid IS NOT NULL +DO UPDATE SET + name = EXCLUDED.name, + sort_name = EXCLUDED.sort_name, + updated_at = now() +RETURNING *; + +-- name: GetArtistByID :one +SELECT * FROM artists WHERE id = $1; + +-- name: GetArtistByName :one +SELECT * FROM artists WHERE name = $1 LIMIT 1; + +-- name: ListArtists :many +SELECT * FROM artists ORDER BY sort_name; diff --git a/internal/db/queries/tracks.sql b/internal/db/queries/tracks.sql new file mode 100644 index 00000000..250a9766 --- /dev/null +++ b/internal/db/queries/tracks.sql @@ -0,0 +1,29 @@ +-- name: UpsertTrack :one +-- file_path is the canonical identity for library scan; mbid is secondary. +INSERT INTO tracks ( + title, album_id, artist_id, track_number, disc_number, + duration_ms, file_path, file_size, file_format, bitrate, mbid, genre +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) +ON CONFLICT (file_path) DO UPDATE SET + title = EXCLUDED.title, + album_id = EXCLUDED.album_id, + artist_id = EXCLUDED.artist_id, + track_number = EXCLUDED.track_number, + disc_number = EXCLUDED.disc_number, + duration_ms = EXCLUDED.duration_ms, + file_size = EXCLUDED.file_size, + file_format = EXCLUDED.file_format, + bitrate = EXCLUDED.bitrate, + mbid = EXCLUDED.mbid, + genre = EXCLUDED.genre, + updated_at = now() +RETURNING *; + +-- name: GetTrackByID :one +SELECT * FROM tracks WHERE id = $1; + +-- name: GetTrackByPath :one +SELECT * FROM tracks WHERE file_path = $1; + +-- name: ListTracksByAlbum :many +SELECT * FROM tracks WHERE album_id = $1 ORDER BY disc_number NULLS LAST, track_number NULLS LAST; diff --git a/internal/db/queries/users.sql b/internal/db/queries/users.sql new file mode 100644 index 00000000..b3174cbe --- /dev/null +++ b/internal/db/queries/users.sql @@ -0,0 +1,13 @@ +-- name: CreateUser :one +INSERT INTO users (username, password_hash, api_token, is_admin) +VALUES ($1, $2, $3, $4) +RETURNING *; + +-- name: GetUserByUsername :one +SELECT * FROM users WHERE username = $1; + +-- name: GetUserByAPIToken :one +SELECT * FROM users WHERE api_token = $1; + +-- name: CountUsers :one +SELECT count(*) FROM users; diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 00000000..fbd44683 --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,13 @@ +version: "2" +sql: + - engine: "postgresql" + queries: "internal/db/queries" + schema: "internal/db/migrations" + gen: + go: + package: "dbq" + out: "internal/db/dbq" + sql_package: "pgx/v5" + emit_json_tags: false + emit_pointers_for_null_types: true + emit_prepared_queries: false