diff --git a/.gitignore b/.gitignore index 2583b5c..270643d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ coverage.out .vscode/ .idea/ .DS_Store +potok.db* diff --git a/cmd/potokd/main.go b/cmd/potokd/main.go index 0c6c77f..f22db2a 100644 --- a/cmd/potokd/main.go +++ b/cmd/potokd/main.go @@ -41,5 +41,6 @@ func main() { slog.Info("Starting HTTP server") handler := httpapi.NewHandler(conn) nethttp.HandleFunc("GET /health", handler.Health) - log.Fatal(nethttp.ListenAndServe(":3000", nil)) + nethttp.HandleFunc("POST /register", handler.Register) + log.Fatal(nethttp.ListenAndServe(cfg.Addr, nil)) } diff --git a/go.mod b/go.mod index 0e5fc01..79993dd 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/gorilla/mux v1.8.1 github.com/spf13/cobra v1.10.2 github.com/zalando/go-keyring v0.2.8 - golang.org/x/crypto v0.45.0 + golang.org/x/crypto v0.55.0 modernc.org/sqlite v1.38.1 ) @@ -33,7 +33,7 @@ require ( github.com/lib/pq v1.10.9 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/mattn/go-sqlite3 v1.14.50 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/zenity v0.10.14 // indirect github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844 // indirect @@ -42,10 +42,10 @@ require ( go.uber.org/atomic v1.7.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/image v0.20.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/term v0.39.0 // indirect - golang.org/x/text v0.31.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.41.0 // indirect modernc.org/libc v1.66.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 585fbe4..0f65f55 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,7 @@ github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f/go.mod h1:Dv9D0NUlAsaQcGQZa5kc5mqR9ua72SmA8VXi4cd+cBw= +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/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= @@ -14,6 +15,7 @@ github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FP 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/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/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -36,12 +38,17 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.50 h1:dmdFvo1XG4MPzA4IkAmE9upVz/Nj31uRoM5+jC8hYbY= +github.com/mattn/go-sqlite3 v1.14.50/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/zenity v0.10.14/go.mod h1:ZBW7uVe/Di3IcRYH0Br8X59pi+O6EPnNIOU66YHpOO4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844/go.mod h1:T1TLSfyWVBRXVGzWd0o9BI4kfoO9InEgfQe4NV3mLz8= +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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= @@ -62,25 +69,39 @@ go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +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/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/image v0.20.0/go.mod h1:0a88To4CYVBAHp5FXJm8o7QbUl37Vd85ply1vyD8auM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= 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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +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/sqlite v1.38.1 h1:jNnIjleVta+DKSAr3TnkKK87EEhjPhBLzi6hvIX9Bas= modernc.org/sqlite v1.38.1/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= diff --git a/internal/server/http/http.go b/internal/server/http/http.go index b7f8594..214c085 100644 --- a/internal/server/http/http.go +++ b/internal/server/http/http.go @@ -1,20 +1,62 @@ package http import ( + "context" + "encoding/json" + "fmt" "net/http" "github.com/michaeltukdev/Potok/internal/server/store" ) +type Store interface { + CreateVault(ctx context.Context, name string, wrappedKey []byte) (store.Vault, error) + VaultByName(ctx context.Context, name string) (store.Vault, error) + ListVaults(ctx context.Context) ([]store.Vault, error) + DeleteVault(ctx context.Context, name string) error + CreateUser(ctx context.Context, email, password string) (store.User, error) +} + type Handler struct { - store *store.Store + store Store } -func NewHandler(s *store.Store) *Handler { +func NewHandler(s Store) *Handler { return &Handler{store: s} } func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { - http.ResponseWriter.WriteHeader(w, http.StatusOK) - _, _ = http.ResponseWriter.Write(w, []byte("OK")) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) +} + +func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { + var body struct { + Email string `json:"email"` + Password string `json:"password"` + } + + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if body.Email == "" || body.Password == "" { + http.Error(w, "email and password are required", http.StatusBadRequest) + return + } + + user, err := h.store.CreateUser(r.Context(), body.Email, body.Password) + if err != nil { + if err == store.ErrUserExists { + http.Error(w, "user already exists", http.StatusConflict) + return + } + + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) + w.Write(fmt.Appendf(nil, "User created: %v", user)) } diff --git a/internal/server/http/http_test.go b/internal/server/http/http_test.go index cab9a17..6fe7159 100644 --- a/internal/server/http/http_test.go +++ b/internal/server/http/http_test.go @@ -1,11 +1,31 @@ package http import ( + "context" "net/http" "net/http/httptest" + "strings" "testing" + + "github.com/michaeltukdev/Potok/internal/server/store" ) +func newTestStore(t *testing.T) *store.Store { + t.Helper() + ctx := context.Background() + + s, err := store.Open(ctx, ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Close() }) + + if err := s.Migrate(); err != nil { + t.Fatalf("migrate: %v", err) + } + return s +} + func TestHealthEndpoint(t *testing.T) { req, _ := http.NewRequest(http.MethodGet, "/health", nil) @@ -21,3 +41,66 @@ func TestHealthEndpoint(t *testing.T) { t.Errorf("expected body %s, got %s", "OK", response.Body.String()) } } + +func TestRegisterEndpoint(t *testing.T) { + tests := []struct { + name string + seed string + input string + wantStatus int + }{ + { + name: "valid", + input: `{"email":"a@example.com","password":"hunter2"}`, + wantStatus: http.StatusCreated, + }, + { + name: "malformed json", + input: `{"email":`, + wantStatus: http.StatusBadRequest, + }, + { + name: "missing email", + input: `{"password":"hunter2"}`, + wantStatus: http.StatusBadRequest, + }, + { + name: "duplicate email", + seed: `{"email":"a@example.com","password":"hunter2"}`, + input: `{"email":"a@example.com","password":"different"}`, + wantStatus: http.StatusConflict, + }, + { + name: "duplicate email different case", + seed: `{"email":"a@example.com","password":"hunter2"}`, + input: `{"email":"A@Example.com","password":"hunter2"}`, + wantStatus: http.StatusConflict, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + handler := NewHandler(newTestStore(t)) + + post := func(body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/register", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.Register(rec, req) + return rec + } + + if test.seed != "" { + if rec := post(test.seed); rec.Code != http.StatusCreated { + t.Fatalf("seed request failed: %d (body: %s)", rec.Code, rec.Body.String()) + } + } + + response := post(test.input) + if response.Code != test.wantStatus { + t.Errorf("expected status code %d, got %d (body: %s)", + test.wantStatus, response.Code, response.Body.String()) + } + }) + } +} diff --git a/internal/server/store/migrate.go b/internal/server/store/migrate.go index a1f9660..7a6bec6 100644 --- a/internal/server/store/migrate.go +++ b/internal/server/store/migrate.go @@ -5,32 +5,35 @@ import ( "fmt" "github.com/golang-migrate/migrate/v4" - "github.com/golang-migrate/migrate/v4/database/postgres" + "github.com/golang-migrate/migrate/v4/database/sqlite" "github.com/golang-migrate/migrate/v4/source/iofs" - "github.com/jackc/pgx/v5/stdlib" "github.com/michaeltukdev/Potok/migrations" ) -func (s *Store) Migrate() error { +func (s *Store) migrator() (*migrate.Migrate, error) { source, err := iofs.New(migrations.FS, ".") if err != nil { - return fmt.Errorf("store: read migrations: %w", err) + return nil, fmt.Errorf("store: read migrations: %w", err) } - db := stdlib.OpenDBFromPool(s.pool) - defer db.Close() - - driver, err := postgres.WithInstance(db, &postgres.Config{}) + driver, err := sqlite.WithInstance(s.db, &sqlite.Config{}) if err != nil { - return fmt.Errorf("store: migration driver: %w", err) + return nil, fmt.Errorf("store: migration driver: %w", err) } - m, err := migrate.NewWithInstance("iofs", source, "postgres", driver) + m, err := migrate.NewWithInstance("iofs", source, "sqlite", driver) if err != nil { - return fmt.Errorf("store: migrator: %w", err) + return nil, fmt.Errorf("store: migrator: %w", err) } + return m, nil +} +func (s *Store) Migrate() error { + m, err := s.migrator() + if err != nil { + return err + } if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { return fmt.Errorf("store: apply migrations: %w", err) } @@ -38,22 +41,9 @@ func (s *Store) Migrate() error { } func (s *Store) Version() (version uint, dirty bool, err error) { - source, err := iofs.New(migrations.FS, ".") - if err != nil { - return 0, false, fmt.Errorf("store: read migrations: %w", err) - } - - db := stdlib.OpenDBFromPool(s.pool) - defer db.Close() - - driver, err := postgres.WithInstance(db, &postgres.Config{}) - if err != nil { - return 0, false, fmt.Errorf("store: migration driver: %w", err) - } - - m, err := migrate.NewWithInstance("iofs", source, "postgres", driver) + m, err := s.migrator() if err != nil { - return 0, false, fmt.Errorf("store: migrator: %w", err) + return 0, false, err } version, dirty, err = m.Version() diff --git a/internal/server/store/store.go b/internal/server/store/store.go index ce67cb6..2040ad5 100644 --- a/internal/server/store/store.go +++ b/internal/server/store/store.go @@ -2,40 +2,66 @@ package store import ( "context" + "database/sql" "fmt" + "strings" "time" - "github.com/jackc/pgx/v5/pgxpool" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" ) +var ErrUserExists = fmt.Errorf("user already exists") + type Store struct { - pool *pgxpool.Pool + db *sql.DB } -type Vault struct{} +type Vault struct { + ID int64 `json:"id"` + Name string `json:"name"` + WrappedKey []byte `json:"-"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type User struct { + ID string `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"password_hash"` + IsAdmin bool `json:"is_admin"` + CreatedAt time.Time `json:"created_at"` +} func Open(ctx context.Context, dsn string) (*Store, error) { - cfg, err := pgxpool.ParseConfig(dsn) + db, err := sql.Open("sqlite", dsn) if err != nil { - return nil, fmt.Errorf("store: parse database url: %w", err) + return nil, fmt.Errorf("store: open: %w", err) } - cfg.MaxConns = 10 - cfg.MaxConnLifetime = time.Hour - pool, err := pgxpool.NewWithConfig(ctx, cfg) - if err != nil { - return nil, fmt.Errorf("store: connect: %w", err) - } + db.SetMaxOpenConns(1) - if err := pool.Ping(ctx); err != nil { - pool.Close() + if err := db.PingContext(ctx); err != nil { + db.Close() return nil, fmt.Errorf("store: ping: %w", err) } - return &Store{pool: pool}, nil + for _, pragma := range []string{ + "PRAGMA foreign_keys = ON", + "PRAGMA journal_mode = WAL", + "PRAGMA busy_timeout = 5000", + } { + if _, err := db.ExecContext(ctx, pragma); err != nil { + db.Close() + return nil, fmt.Errorf("store: %s: %w", pragma, err) + } + } + + return &Store{db: db}, nil } -func (s *Store) Close() { s.pool.Close() } +// Extra +func (s *Store) Close() error { return s.db.Close() } func (s *Store) CreateVault(ctx context.Context, name string, wrappedKey []byte) (Vault, error) { return Vault{}, nil @@ -52,3 +78,35 @@ func (s *Store) ListVaults(ctx context.Context) ([]Vault, error) { func (s *Store) DeleteVault(ctx context.Context, name string) error { return nil } + +func (s *Store) CreateUser(ctx context.Context, email, password string) (User, error) { + email = strings.ToLower(email) + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return User{}, fmt.Errorf("store: create user: %w", err) + } + + user := User{ + ID: uuid.NewString(), + Email: email, + PasswordHash: string(hash), + } + + err = s.db.QueryRowContext(ctx, ` + INSERT INTO users (id, email, password_hash) + VALUES (?, ?, ?) + ON CONFLICT (email) DO NOTHING + RETURNING id, email, is_admin, created_at`, + user.ID, user.Email, user.PasswordHash, + ).Scan(&user.ID, &user.Email, &user.IsAdmin, &user.CreatedAt) + + if err != nil { + if err == sql.ErrNoRows { + return User{}, ErrUserExists + } + + return User{}, fmt.Errorf("store: create user: %w", err) + } + return user, nil +} diff --git a/migrations/0001_init.up.sql b/migrations/0001_init.up.sql index 2d139ed..e573e87 100644 --- a/migrations/0001_init.up.sql +++ b/migrations/0001_init.up.sql @@ -1,31 +1,30 @@ CREATE TABLE users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - email TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - is_admin BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE api_keys ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, - label TEXT NOT NULL, - key_hash BYTEA NOT NULL UNIQUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - last_used_at TIMESTAMPTZ + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + label TEXT NOT NULL, + key_hash BLOB NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_used_at TIMESTAMP ); CREATE INDEX api_keys_user_id_idx ON api_keys (user_id); CREATE TABLE vaults ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, - name TEXT NOT NULL, - wrapped_key BYTEA NOT NULL, - generation BIGINT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + name TEXT NOT NULL, + wrapped_key BLOB NOT NULL, + generation INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE (user_id, name) ); diff --git a/migrations/0002_manifests_and_blobs.up.sql b/migrations/0002_manifests_and_blobs.up.sql index 9bacea4..f3756d2 100644 --- a/migrations/0002_manifests_and_blobs.up.sql +++ b/migrations/0002_manifests_and_blobs.up.sql @@ -1,15 +1,14 @@ CREATE TABLE manifests ( - vault_id UUID PRIMARY KEY REFERENCES vaults (id) ON DELETE CASCADE, - generation BIGINT NOT NULL, - ciphertext BYTEA NOT NULL, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + vault_id TEXT PRIMARY KEY REFERENCES vaults (id) ON DELETE CASCADE, + generation INTEGER NOT NULL, + ciphertext BLOB NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE blobs ( - vault_id UUID NOT NULL REFERENCES vaults (id) ON DELETE CASCADE, - id TEXT NOT NULL, - size_bytes BIGINT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - + vault_id TEXT NOT NULL REFERENCES vaults (id) ON DELETE CASCADE, + id TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (vault_id, id) );