feat: initial backend scaffolding

migrating away from convex

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2025-11-10 00:19:30 +00:00
parent 5cc13a34b2
commit 1feac70f7f
16 changed files with 861 additions and 0 deletions

View File

@@ -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)))
}

View File

@@ -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)
}
}

39
apps/backend/go.mod Normal file
View File

@@ -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
)

61
apps/backend/go.sum Normal file
View File

@@ -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=

View File

@@ -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
}

View File

@@ -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,
})
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"`
}

View File

@@ -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)
}

View File

@@ -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();

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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
}