From 1feac70f7ffcb851b4d9f2d3a9545d2e70d1cf27 Mon Sep 17 00:00:00 2001 From: kenneth Date: Mon, 10 Nov 2025 00:19:30 +0000 Subject: [PATCH] feat: initial backend scaffolding migrating away from convex Co-authored-by: Ona --- .devcontainer/devcontainer.json | 4 + apps/backend/cmd/drexa/main.go | 22 +++ apps/backend/cmd/migration/main.go | 13 ++ apps/backend/go.mod | 39 ++++++ apps/backend/go.sum | 61 ++++++++ apps/backend/internal/auth/err.go | 19 +++ apps/backend/internal/auth/http.go | 90 ++++++++++++ apps/backend/internal/auth/password.go | 132 ++++++++++++++++++ apps/backend/internal/auth/service.go | 112 +++++++++++++++ apps/backend/internal/auth/tokens.go | 91 ++++++++++++ apps/backend/internal/auth/user.go | 17 +++ apps/backend/internal/database/migrate.go | 17 +++ .../internal/database/migrations/initial.sql | 122 ++++++++++++++++ apps/backend/internal/database/postgres.go | 15 ++ apps/backend/internal/drexa/err.go | 23 +++ apps/backend/internal/drexa/server.go | 84 +++++++++++ 16 files changed, 861 insertions(+) create mode 100644 apps/backend/cmd/drexa/main.go create mode 100644 apps/backend/cmd/migration/main.go create mode 100644 apps/backend/go.mod create mode 100644 apps/backend/go.sum create mode 100644 apps/backend/internal/auth/err.go create mode 100644 apps/backend/internal/auth/http.go create mode 100644 apps/backend/internal/auth/password.go create mode 100644 apps/backend/internal/auth/service.go create mode 100644 apps/backend/internal/auth/tokens.go create mode 100644 apps/backend/internal/auth/user.go create mode 100644 apps/backend/internal/database/migrate.go create mode 100644 apps/backend/internal/database/migrations/initial.sql create mode 100644 apps/backend/internal/database/postgres.go create mode 100644 apps/backend/internal/drexa/err.go create mode 100644 apps/backend/internal/drexa/server.go diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 42d9600..744b39b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -12,6 +12,10 @@ }, "ghcr.io/tailscale/codespace/tailscale": { "version": "latest" + }, + "ghcr.io/devcontainers/features/go:1": { + "version": "1.25.4", + "golangciLintVersion": "2.6.1" } }, "postCreateCommand": "./scripts/setup-git.sh", diff --git a/apps/backend/cmd/drexa/main.go b/apps/backend/cmd/drexa/main.go new file mode 100644 index 0000000..525d1c3 --- /dev/null +++ b/apps/backend/cmd/drexa/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "fmt" + "log" + + "github.com/get-drexa/drexa/internal/drexa" + "github.com/joho/godotenv" +) + +func main() { + _ = godotenv.Load() + + config, err := drexa.ServerConfigFromEnv() + if err != nil { + log.Fatal(err) + } + + server := drexa.NewServer(*config) + + log.Fatal(server.Listen(fmt.Sprintf(":%d", config.Port))) +} diff --git a/apps/backend/cmd/migration/main.go b/apps/backend/cmd/migration/main.go new file mode 100644 index 0000000..c5aa925 --- /dev/null +++ b/apps/backend/cmd/migration/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "log" + + "github.com/get-drexa/drexa/internal/database" +) + +func main() { + if err := database.RunMigrations(); err != nil { + log.Fatalf("Failed to run migrations: %v", err) + } +} diff --git a/apps/backend/go.mod b/apps/backend/go.mod new file mode 100644 index 0000000..ec66e20 --- /dev/null +++ b/apps/backend/go.mod @@ -0,0 +1,39 @@ +module github.com/get-drexa/drexa + +go 1.25.4 + +require ( + github.com/gofiber/fiber/v2 v2.52.9 + github.com/google/uuid v1.6.0 + github.com/uptrace/bun v1.2.15 + golang.org/x/crypto v0.40.0 +) + +require ( + github.com/joho/godotenv v1.5.1 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect + mellium.im/sasl v0.3.2 // indirect +) + +require ( + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect + github.com/uptrace/bun/dialect/pgdialect v1.2.15 + github.com/uptrace/bun/driver/pgdriver v1.2.15 + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + golang.org/x/sys v0.34.0 // indirect +) diff --git a/apps/backend/go.sum b/apps/backend/go.sum new file mode 100644 index 0000000..c79049d --- /dev/null +++ b/apps/backend/go.sum @@ -0,0 +1,61 @@ +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +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/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw= +github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +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/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +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-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +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/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= +github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= +github.com/uptrace/bun v1.2.15 h1:Ut68XRBLDgp9qG9QBMa9ELWaZOmzHNdczHQdrOZbEFE= +github.com/uptrace/bun v1.2.15/go.mod h1:Eghz7NonZMiTX/Z6oKYytJ0oaMEJ/eq3kEV4vSqG038= +github.com/uptrace/bun/dialect/pgdialect v1.2.15 h1:er+/3giAIqpfrXJw+KP9B7ujyQIi5XkPnFmgjAVL6bA= +github.com/uptrace/bun/dialect/pgdialect v1.2.15/go.mod h1:QSiz6Qpy9wlGFsfpf7UMSL6mXAL1jDJhFwuOVacCnOQ= +github.com/uptrace/bun/driver/pgdriver v1.2.15 h1:eZZ60ZtUUE6jjv6VAI1pCMaTgtx3sxmChQzwbvchOOo= +github.com/uptrace/bun/driver/pgdriver v1.2.15/go.mod h1:s2zz/BAeScal4KLFDI8PURwATN8s9RDBsElEbnPAjv4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +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/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +mellium.im/sasl v0.3.2 h1:PT6Xp7ccn9XaXAnJ03FcEjmAn7kK1x7aoXV6F+Vmrl0= +mellium.im/sasl v0.3.2/go.mod h1:NKXDi1zkr+BlMHLQjY3ofYuU4KSPFxknb8mfEu6SveY= diff --git a/apps/backend/internal/auth/err.go b/apps/backend/internal/auth/err.go new file mode 100644 index 0000000..ab887e6 --- /dev/null +++ b/apps/backend/internal/auth/err.go @@ -0,0 +1,19 @@ +package auth + +import "fmt" + +type InvalidAccessTokenError struct { + err error +} + +func newInvalidAccessTokenError(err error) *InvalidAccessTokenError { + return &InvalidAccessTokenError{err} +} + +func (e *InvalidAccessTokenError) Error() string { + return fmt.Sprintf("invalid access token: %v", e.err) +} + +func (e *InvalidAccessTokenError) Unwrap() error { + return e.err +} diff --git a/apps/backend/internal/auth/http.go b/apps/backend/internal/auth/http.go new file mode 100644 index 0000000..c899f2f --- /dev/null +++ b/apps/backend/internal/auth/http.go @@ -0,0 +1,90 @@ +package auth + +import ( + "errors" + + "github.com/gofiber/fiber/v2" +) + +const authServiceKey = "authService" + +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type registerRequest struct { + Email string `json:"email"` + Password string `json:"password"` + DisplayName string `json:"displayName"` +} + +type loginResponse struct { + User User `json:"user"` + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` +} + +func RegisterAPIRoutes(api fiber.Router, s *Service) { + auth := api.Group("/auth", func(c *fiber.Ctx) error { + c.Locals(authServiceKey, s) + return c.Next() + }) + + auth.Post("/login", login) + auth.Post("/register", register) +} + +func mustAuthService(c *fiber.Ctx) *Service { + return c.Locals(authServiceKey).(*Service) +} + +func login(c *fiber.Ctx) error { + s := mustAuthService(c) + + req := new(loginRequest) + if err := c.BodyParser(req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request"}) + } + + result, err := s.LoginWithEmailAndPassword(c.Context(), req.Email, req.Password) + if err != nil { + if errors.Is(err, ErrInvalidCredentials) { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"}) + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Internal server error"}) + } + + return c.JSON(loginResponse{ + User: result.User, + AccessToken: result.AccessToken, + RefreshToken: result.RefreshToken, + }) +} + +func register(c *fiber.Ctx) error { + s := mustAuthService(c) + + req := new(registerRequest) + if err := c.BodyParser(req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request"}) + } + + result, err := s.Register(c.Context(), registerOptions{ + email: req.Email, + password: req.Password, + displayName: req.DisplayName, + }) + if err != nil { + if errors.Is(err, ErrUserExists) { + return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "User already exists"}) + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Internal server error"}) + } + + return c.JSON(loginResponse{ + User: result.User, + AccessToken: result.AccessToken, + RefreshToken: result.RefreshToken, + }) +} diff --git a/apps/backend/internal/auth/password.go b/apps/backend/internal/auth/password.go new file mode 100644 index 0000000..58316d1 --- /dev/null +++ b/apps/backend/internal/auth/password.go @@ -0,0 +1,132 @@ +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +// argon2id parameters +const ( + memory = 64 * 1024 + iterations = 3 + parallelism = 2 + saltLength = 16 + keyLength = 32 +) + +var ( + ErrInvalidHash = errors.New("invalid hash format") + ErrIncompatibleHash = errors.New("incompatible hash algorithm") + ErrIncompatibleVersion = errors.New("incompatible argon2 version") +) + +type argon2Hash struct { + memory uint32 + iterations uint32 + parallelism uint8 + salt []byte + hash []byte +} + +func HashPassword(password string) (string, error) { + salt := make([]byte, saltLength) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("failed to generate salt: %w", err) + } + + hash := argon2.IDKey( + []byte(password), + salt, + iterations, + memory, + parallelism, + keyLength, + ) + + b64Salt := base64.RawStdEncoding.EncodeToString(salt) + b64Hash := base64.RawStdEncoding.EncodeToString(hash) + + encodedHash := fmt.Sprintf( + "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, + memory, + iterations, + parallelism, + b64Salt, + b64Hash, + ) + + return encodedHash, nil +} + +func VerifyPassword(password, encodedHash string) (bool, error) { + h, err := decodeHash(encodedHash) + if err != nil { + return false, err + } + + otherHash := argon2.IDKey( + []byte(password), + h.salt, + h.iterations, + h.memory, + h.parallelism, + uint32(len(h.hash)), + ) + + if subtle.ConstantTimeCompare(h.hash, otherHash) == 1 { + return true, nil + } + + return false, nil +} + +func decodeHash(encodedHash string) (*argon2Hash, error) { + parts := strings.Split(encodedHash, "$") + if len(parts) != 6 { + return nil, ErrInvalidHash + } + + if parts[1] != "argon2id" { + return nil, ErrIncompatibleHash + } + + var version int + if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil { + return nil, fmt.Errorf("failed to parse version: %w", err) + } + if version != argon2.Version { + return nil, ErrIncompatibleVersion + } + + h := &argon2Hash{} + if _, err := fmt.Sscanf( + parts[3], + "m=%d,t=%d,p=%d", + &h.memory, + &h.iterations, + &h.parallelism, + ); err != nil { + return nil, fmt.Errorf("failed to parse parameters: %w", err) + } + + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return nil, fmt.Errorf("failed to decode salt: %w", err) + } + h.salt = salt + + hash, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return nil, fmt.Errorf("failed to decode hash: %w", err) + } + h.hash = hash + + return h, nil +} diff --git a/apps/backend/internal/auth/service.go b/apps/backend/internal/auth/service.go new file mode 100644 index 0000000..228ea84 --- /dev/null +++ b/apps/backend/internal/auth/service.go @@ -0,0 +1,112 @@ +package auth + +import ( + "context" + "encoding/hex" + "errors" + + "github.com/uptrace/bun" +) + +type LoginResult struct { + User User + AccessToken string + RefreshToken string +} + +var ErrInvalidCredentials = errors.New("invalid credentials") +var ErrUserExists = errors.New("user already exists") + +type Service struct { + db *bun.DB + tokenConfig TokenConfig +} + +type registerOptions struct { + displayName string + email string + password string +} + +func NewService(db *bun.DB, tokenConfig TokenConfig) *Service { + return &Service{ + db: db, + tokenConfig: tokenConfig, + } +} + +func (s *Service) LoginWithEmailAndPassword(ctx context.Context, email, password string) (*LoginResult, error) { + var user User + + err := s.db.NewSelect().Model(&user).Where("email = ?", email).Scan(ctx) + if err != nil { + return nil, err + } + + ok, err := VerifyPassword(password, user.Password) + if err != nil || !ok { + return nil, ErrInvalidCredentials + } + + at, err := GenerateAccessToken(&user, &s.tokenConfig) + if err != nil { + return nil, err + } + + rt, err := GenerateRefreshToken(&user, &s.tokenConfig) + if err != nil { + return nil, err + } + + _, err = s.db.NewInsert().Model(rt).Exec(ctx) + if err != nil { + return nil, err + } + + return &LoginResult{ + User: user, + AccessToken: at, + RefreshToken: hex.EncodeToString(rt.Token), + }, nil +} + +func (s *Service) Register(ctx context.Context, opts registerOptions) (*LoginResult, error) { + user := User{ + Email: opts.email, + DisplayName: opts.displayName, + } + + exists, err := s.db.NewSelect().Model(&user).Where("email = ?", opts.email).Exists(ctx) + if err != nil { + return nil, err + } + if exists { + return nil, ErrUserExists + } + + user.Password, err = HashPassword(opts.password) + if err != nil { + return nil, err + } + + _, err = s.db.NewInsert().Model(&user).Exec(ctx) + if err != nil { + return nil, err + } + + at, err := GenerateAccessToken(&user, &s.tokenConfig) + if err != nil { + return nil, err + } + + rt, err := GenerateRefreshToken(&user, &s.tokenConfig) + if err != nil { + return nil, err + } + + return &LoginResult{ + User: user, + AccessToken: at, + RefreshToken: hex.EncodeToString(rt.Token), + }, nil +} diff --git a/apps/backend/internal/auth/tokens.go b/apps/backend/internal/auth/tokens.go new file mode 100644 index 0000000..74c9898 --- /dev/null +++ b/apps/backend/internal/auth/tokens.go @@ -0,0 +1,91 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/uptrace/bun" +) + +const ( + accessTokenValidFor = time.Minute * 15 + refreshTokenByteLength = 32 + refreshTokenValidFor = time.Hour * 24 * 30 +) + +type TokenConfig struct { + Issuer string + Audience string + SecretKey []byte +} + +type RefreshToken struct { + bun.BaseModel `bun:"refresh_tokens"` + + ID uuid.UUID `bun:",pk,type:uuid"` + UserID uuid.UUID `bun:"user_id,notnull"` + Token []byte `bun:"-"` + TokenHash string `bun:"token_hash,notnull"` + ExpiresAt time.Time `bun:"expires_at,notnull"` + CreatedAt time.Time `bun:"created_at,notnull"` +} + +func GenerateAccessToken(user *User, c *TokenConfig) (string, error) { + now := time.Now() + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + Issuer: c.Issuer, + Audience: jwt.ClaimStrings{c.Audience}, + Subject: user.ID.String(), + ExpiresAt: jwt.NewNumericDate(now.Add(accessTokenValidFor)), + IssuedAt: jwt.NewNumericDate(now), + }) + + signed, err := token.SignedString(c.SecretKey) + if err != nil { + return "", fmt.Errorf("failed to sign token: %w", err) + } + + return signed, nil +} + +func GenerateRefreshToken(user *User, c *TokenConfig) (*RefreshToken, error) { + now := time.Now() + + buf := make([]byte, refreshTokenByteLength) + if _, err := rand.Read(buf); err != nil { + return nil, fmt.Errorf("failed to generate refresh token: %w", err) + } + + id, err := uuid.NewV7() + if err != nil { + return nil, fmt.Errorf("failed to generate refresh token: %w", err) + } + + h := sha256.Sum256(buf) + hex := hex.EncodeToString(h[:]) + + return &RefreshToken{ + ID: id, + UserID: user.ID, + Token: buf, + TokenHash: hex, + ExpiresAt: now.Add(refreshTokenValidFor), + CreatedAt: now, + }, nil +} + +func ParseAccessToken(token string, c *TokenConfig) (*jwt.RegisteredClaims, error) { + parsed, err := jwt.ParseWithClaims(token, &jwt.RegisteredClaims{}, func(token *jwt.Token) (any, error) { + return c.SecretKey, nil + }, jwt.WithIssuer(c.Issuer), jwt.WithExpirationRequired(), jwt.WithAudience(c.Audience)) + if err != nil { + return nil, newInvalidAccessTokenError(err) + } + return parsed.Claims.(*jwt.RegisteredClaims), nil +} diff --git a/apps/backend/internal/auth/user.go b/apps/backend/internal/auth/user.go new file mode 100644 index 0000000..c95ef8b --- /dev/null +++ b/apps/backend/internal/auth/user.go @@ -0,0 +1,17 @@ +package auth + +import ( + "github.com/google/uuid" + "github.com/uptrace/bun" +) + +type User struct { + bun.BaseModel `bun:"users"` + + ID uuid.UUID `bun:",pk,type:uuid" json:"id"` + DisplayName string `bun:"display_name,notnull" json:"displayName"` + Email string `bun:"email,unique,notnull" json:"email"` + Password string `bun:"password,notnull" json:"-"` + StorageUsageBytes int64 `bun:"storage_usage_bytes,notnull" json:"storageUsageBytes"` + StorageQuotaBytes int64 `bun:"storage_quota_bytes,notnull" json:"storageQuotaBytes"` +} diff --git a/apps/backend/internal/database/migrate.go b/apps/backend/internal/database/migrate.go new file mode 100644 index 0000000..5ecf76d --- /dev/null +++ b/apps/backend/internal/database/migrate.go @@ -0,0 +1,17 @@ +package database + +import ( + "embed" + + "github.com/uptrace/bun/migrate" +) + +//go:embed migrations/*.sql +var sqlMigrations embed.FS + +// RunMigrations discovers and runs all migrations in the migrations directory. +// Currently, the migrations directory is in internal/db/migrations. +func RunMigrations() error { + m := migrate.NewMigrations() + return m.Discover(sqlMigrations) +} diff --git a/apps/backend/internal/database/migrations/initial.sql b/apps/backend/internal/database/migrations/initial.sql new file mode 100644 index 0000000..27dc103 --- /dev/null +++ b/apps/backend/internal/database/migrations/initial.sql @@ -0,0 +1,122 @@ +-- Enable UUID extension for UUIDv7 support +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- UUIDv7 generation function (timestamp-ordered UUIDs) +-- Based on the draft RFC: https://datatracker.ietf.org/doc/html/draft-peabody-dispatch-new-uuid-format +CREATE OR REPLACE FUNCTION uuid_generate_v7() +RETURNS UUID +AS $$ +DECLARE + unix_ts_ms BIGINT; + uuid_bytes BYTEA; +BEGIN + unix_ts_ms = (EXTRACT(EPOCH FROM CLOCK_TIMESTAMP()) * 1000)::BIGINT; + uuid_bytes = OVERLAY(gen_random_bytes(16) PLACING + SUBSTRING(INT8SEND(unix_ts_ms) FROM 3) FROM 1 FOR 6 + ); + -- Set version (7) and variant bits + uuid_bytes = SET_BYTE(uuid_bytes, 6, (GET_BYTE(uuid_bytes, 6) & 15) | 112); + uuid_bytes = SET_BYTE(uuid_bytes, 8, (GET_BYTE(uuid_bytes, 8) & 63) | 128); + RETURN ENCODE(uuid_bytes, 'hex')::UUID; +END; +$$ LANGUAGE plpgsql VOLATILE; + +-- ============================================================================ +-- Application Tables +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + display_name TEXT, + email TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + storage_usage_bytes BIGINT NOT NULL, + storage_quota_bytes BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_users_email ON users(email); + +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id); +CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash); +CREATE INDEX idx_refresh_tokens_expires_at ON refresh_tokens(expires_at); + +CREATE TABLE IF NOT EXISTS directories ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + parent_id UUID REFERENCES directories(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT unique_directory_path UNIQUE NULLS NOT DISTINCT (user_id, parent_id, name, deleted_at) +); + +CREATE INDEX idx_directories_user_id ON directories(user_id, deleted_at); +CREATE INDEX idx_directories_path ON directories(user_id, path, deleted_at); + +CREATE TABLE IF NOT EXISTS files ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + directory_id UUID REFERENCES directories(id) ON DELETE CASCADE, + name TEXT NOT NULL, + size BIGINT NOT NULL, + mime_type TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + last_accessed_at TIMESTAMPTZ, + CONSTRAINT unique_file_in_directory UNIQUE NULLS NOT DISTINCT (user_id, directory_id, name, deleted_at) +); + +CREATE INDEX idx_files_user_id ON files(user_id, deleted_at); +CREATE INDEX idx_files_directory_id ON files(directory_id) WHERE directory_id IS NOT NULL; +CREATE INDEX idx_files_path ON files(user_id, path, deleted_at); +CREATE INDEX idx_files_deleted_at ON files(deleted_at) WHERE deleted_at IS NOT NULL; +CREATE INDEX idx_files_last_accessed_at ON files(user_id, deleted_at, last_accessed_at); + +CREATE TABLE IF NOT EXISTS file_shares ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE, + share_token TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_file_shares_share_token ON file_shares(share_token); +CREATE INDEX idx_file_shares_file_id ON file_shares(file_id); +CREATE INDEX idx_file_shares_expires_at ON file_shares(expires_at) WHERE expires_at IS NOT NULL; + +-- ============================================================================ +-- Triggers for updated_at timestamps +-- ============================================================================ + +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_directories_updated_at BEFORE UPDATE ON directories + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_files_updated_at BEFORE UPDATE ON files + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_file_shares_updated_at BEFORE UPDATE ON file_shares + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); \ No newline at end of file diff --git a/apps/backend/internal/database/postgres.go b/apps/backend/internal/database/postgres.go new file mode 100644 index 0000000..2022252 --- /dev/null +++ b/apps/backend/internal/database/postgres.go @@ -0,0 +1,15 @@ +package database + +import ( + "database/sql" + + "github.com/uptrace/bun" + "github.com/uptrace/bun/dialect/pgdialect" + "github.com/uptrace/bun/driver/pgdriver" +) + +func NewFromPostgres(url string) *bun.DB { + sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(url))) + db := bun.NewDB(sqldb, pgdialect.New()) + return db +} diff --git a/apps/backend/internal/drexa/err.go b/apps/backend/internal/drexa/err.go new file mode 100644 index 0000000..cde040a --- /dev/null +++ b/apps/backend/internal/drexa/err.go @@ -0,0 +1,23 @@ +package drexa + +import ( + "fmt" + "strings" +) + +type ServerConfigError struct { + Errors []error +} + +func NewServerConfigError(errs ...error) *ServerConfigError { + return &ServerConfigError{Errors: errs} +} + +func (e *ServerConfigError) Error() string { + sb := strings.Builder{} + sb.WriteString("invalid server config:\n") + for _, err := range e.Errors { + sb.WriteString(fmt.Sprintf(" - %s\n", err.Error())) + } + return sb.String() +} diff --git a/apps/backend/internal/drexa/server.go b/apps/backend/internal/drexa/server.go new file mode 100644 index 0000000..5b2ce3e --- /dev/null +++ b/apps/backend/internal/drexa/server.go @@ -0,0 +1,84 @@ +package drexa + +import ( + "encoding/hex" + "errors" + "fmt" + "os" + "strconv" + + "github.com/get-drexa/drexa/internal/auth" + "github.com/get-drexa/drexa/internal/database" + "github.com/gofiber/fiber/v2" +) + +type ServerConfig struct { + Port int + PostgresURL string + JWTIssuer string + JWTAudience string + JWTSecretKey []byte +} + +func NewServer(c ServerConfig) *fiber.App { + app := fiber.New() + db := database.NewFromPostgres(c.PostgresURL) + + authService := auth.NewService(db, auth.TokenConfig{ + Issuer: c.JWTIssuer, + Audience: c.JWTAudience, + SecretKey: c.JWTSecretKey, + }) + + api := app.Group("/api") + auth.RegisterAPIRoutes(api, authService) + + return app +} + +// ServerConfigFromEnv creates a ServerConfig from environment variables. +func ServerConfigFromEnv() (*ServerConfig, error) { + c := ServerConfig{ + PostgresURL: os.Getenv("POSTGRES_URL"), + JWTIssuer: os.Getenv("JWT_ISSUER"), + JWTAudience: os.Getenv("JWT_AUDIENCE"), + } + + errs := []error{} + + keyHex := os.Getenv("JWT_SECRET_KEY") + if keyHex == "" { + errs = append(errs, errors.New("JWT_SECRET_KEY is required")) + } else { + k, err := hex.DecodeString(keyHex) + if err != nil { + errs = append(errs, fmt.Errorf("failed to decode JWT_SECRET_KEY: %w", err)) + } + c.JWTSecretKey = k + } + + p, err := strconv.Atoi(os.Getenv("PORT")) + if err != nil { + errs = append(errs, fmt.Errorf("failed to parse PORT: %w", err)) + } + c.Port = p + + if c.PostgresURL == "" { + errs = append(errs, errors.New("POSTGRES_URL is required")) + } + if c.JWTIssuer == "" { + errs = append(errs, errors.New("JWT_ISSUER is required")) + } + if c.JWTAudience == "" { + errs = append(errs, errors.New("JWT_AUDIENCE is required")) + } + if len(c.JWTSecretKey) == 0 { + errs = append(errs, errors.New("JWT_SECRET_KEY is required")) + } + + if len(errs) > 0 { + return nil, NewServerConfigError(errs...) + } + + return &c, nil +}