diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ecfa611 --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +# App +APP_NAME=trustcontact +APP_ENV=develop +APP_PORT=3000 +APP_BASE_URL=http://localhost:3000 +APP_BUILD_HASH=dev + +# Database +DB_DRIVER=sqlite +DB_SQLITE_PATH=data/app.sqlite3 +DB_POSTGRES_DSN= + +# CORS (comma-separated) +CORS_ORIGINS=http://localhost:3000 +CORS_HEADERS=Origin,Content-Type,Accept,Authorization,HX-Request +CORS_METHODS=GET,POST,PUT,PATCH,DELETE,OPTIONS +CORS_CREDENTIALS=true + +# Sessions +SESSION_KEY=change-me-in-prod + +# SMTP / Email sink +SMTP_HOST=localhost +SMTP_PORT=1025 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_FROM=noreply@example.test +EMAIL_SINK_DIR=data/email-sink + +# Flags +AUTO_MIGRATE=true +SEED_ENABLED=false diff --git a/.gitignore b/.gitignore index 78fd76c..69b2134 100644 --- a/.gitignore +++ b/.gitignore @@ -19,12 +19,16 @@ tmp/ # Environment .env .env.* +!.env.example # Editors / OS .DS_Store .idea/ .vscode/ -# Dev database/files -data/* -!data/.gitkeep +# Dev data and local state +/data/ +*.sqlite +*.sqlite3 +*.db +/email-sink/ diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..78a07cc --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "log" + + "trustcontact/internal/app" + "trustcontact/internal/config" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("config load failed: %v", err) + } + + server, err := app.NewApp(cfg) + if err != nil { + log.Fatalf("app init failed: %v", err) + } + + if err := server.Listen(":" + cfg.Port); err != nil { + log.Fatalf("server listen failed: %v", err) + } +} diff --git a/codex.md b/codex-prompt/codex.md similarity index 100% rename from codex.md rename to codex-prompt/codex.md diff --git a/codex-prompt/prompt-2..txt b/codex-prompt/prompt-2..txt new file mode 100644 index 0000000..6ed10b8 --- /dev/null +++ b/codex-prompt/prompt-2..txt @@ -0,0 +1,19 @@ +Implementa internal/db (db.go, migrate.go, seed.go) con GORM. + +- db.go: Open(cfg) (*gorm.DB, error) con switch sqlite/postgres. + - sqlite usa cfg.SQLitePath, crea directory se serve. + - postgres usa cfg.PostgresDSN. + - logger più verboso in develop. + +- migrate.go: Migrate(db) che fa AutoMigrate su tutti i modelli (User, EmailVerificationToken, PasswordResetToken). + - esegui solo se cfg.AutoMigrate=true (gestisci in app.go o in migrate.go). + +- seed.go: Seed(db) idempotente se cfg.SeedEnabled=true: + - in develop crea: + - admin@example.com (role=admin, verified=true, password="password") + - user@example.com (role=user, verified=true, password="password") + - crea anche utenti demo aggiuntivi per tabella. + - usa upsert by email (GORM clauses OnConflict dove possibile). + - NON loggare password in chiaro. + +Aggiorna/crea internal/models con i modelli necessari. \ No newline at end of file diff --git a/codex-prompt/prompt-3.txt b/codex-prompt/prompt-3.txt new file mode 100644 index 0000000..4eb68f8 --- /dev/null +++ b/codex-prompt/prompt-3.txt @@ -0,0 +1,19 @@ +Implementa internal/models e internal/auth. + +- internal/models/user.go: + - User: ID, Email unique, PasswordHash, EmailVerified, Role (default user), timestamps. + +- internal/models/auth_tokens.go: + - EmailVerificationToken: UserID, TokenHash unique, ExpiresAt, timestamps + - PasswordResetToken: UserID, TokenHash unique, ExpiresAt, timestamps + +- internal/auth/passwords.go: + - HashPassword(plain) -> hash (bcrypt) + - ComparePassword(hash, plain) -> bool/error + +- internal/auth/tokens.go: + - NewToken() -> plainToken (base64url random 32+ bytes) + - HashToken(plainToken) -> hex/bytes SHA-256 string + - ExpiresAt helpers (verify 24h, reset 1h) + +Assicurati che nel DB venga salvato SOLO l’hash del token. \ No newline at end of file diff --git a/go.mod b/go.mod index fa9d736..21dd3c0 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,35 @@ module trustcontact go 1.25.4 + +require ( + github.com/gofiber/fiber/v2 v2.52.11 + github.com/joho/godotenv v1.5.1 + gorm.io/driver/postgres v1.6.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/crypto v0.31.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..42ed2e9 --- /dev/null +++ b/go.sum @@ -0,0 +1,69 @@ +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.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/gofiber/fiber/v2 v2.52.11 h1:5f4yzKLcBcF8ha1GQTWB+mpblWz3Vz6nSAbTL31HkWs= +github.com/gofiber/fiber/v2 v2.52.11/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +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/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/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +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.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +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-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +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/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +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.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +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= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +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.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..967677f --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,55 @@ +package app + +import ( + "fmt" + "strings" + + "trustcontact/internal/config" + "trustcontact/internal/db" + apphttp "trustcontact/internal/http" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/session" +) + +func NewApp(cfg *config.Config) (*fiber.App, error) { + if cfg == nil { + return nil, fmt.Errorf("config is nil") + } + + app := fiber.New() + + app.Use(cors.New(cors.Config{ + AllowOrigins: strings.Join(cfg.CORS.Origins, ","), + AllowHeaders: strings.Join(cfg.CORS.Headers, ","), + AllowMethods: strings.Join(cfg.CORS.Methods, ","), + AllowCredentials: cfg.CORS.Credentials, + })) + + store := session.New(session.Config{ + CookieHTTPOnly: true, + CookieSecure: cfg.Env == config.EnvProd, + }) + + database, err := db.Open(cfg) + if err != nil { + return nil, fmt.Errorf("open db: %w", err) + } + + if cfg.AutoMigrate { + if err := db.Migrate(database); err != nil { + return nil, fmt.Errorf("migrate db: %w", err) + } + } + + if cfg.SeedEnabled && cfg.Env == config.EnvDevelop { + if err := db.Seed(database); err != nil { + return nil, fmt.Errorf("seed db: %w", err) + } + } + + apphttp.RegisterRoutes(app, store, database, cfg) + + return app, nil +} diff --git a/internal/auth/passwords.go b/internal/auth/passwords.go new file mode 100644 index 0000000..5afa4e4 --- /dev/null +++ b/internal/auth/passwords.go @@ -0,0 +1,24 @@ +package auth + +import "golang.org/x/crypto/bcrypt" + +func HashPassword(plain string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) + if err != nil { + return "", err + } + + return string(hash), nil +} + +func ComparePassword(hash string, plain string) (bool, error) { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) + if err != nil { + if err == bcrypt.ErrMismatchedHashAndPassword { + return false, nil + } + return false, err + } + + return true, nil +} diff --git a/internal/auth/tokens.go b/internal/auth/tokens.go new file mode 100644 index 0000000..a5e676f --- /dev/null +++ b/internal/auth/tokens.go @@ -0,0 +1,33 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "time" +) + +const tokenBytes = 32 + +func NewToken() (string, error) { + buf := make([]byte, tokenBytes) + if _, err := rand.Read(buf); err != nil { + return "", err + } + + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +func HashToken(plainToken string) string { + sum := sha256.Sum256([]byte(plainToken)) + return hex.EncodeToString(sum[:]) +} + +func VerifyTokenExpiresAt(now time.Time) time.Time { + return now.UTC().Add(24 * time.Hour) +} + +func ResetTokenExpiresAt(now time.Time) time.Time { + return now.UTC().Add(1 * time.Hour) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..b390437 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,198 @@ +package config + +import ( + "errors" + "fmt" + "os" + "strconv" + "strings" + + "github.com/joho/godotenv" +) + +const ( + EnvDevelop = "develop" + EnvProd = "prod" + + DBDriverSQLite = "sqlite" + DBDriverPostgres = "postgres" +) + +type Config struct { + AppName string + Env string + Port string + BaseURL string + BuildHash string + + DBDriver string + SQLitePath string + PostgresDSN string + + CORS CORSConfig + + SessionKey string + + SMTP SMTPConfig + + EmailSinkDir string + + AutoMigrate bool + SeedEnabled bool +} + +type CORSConfig struct { + Origins []string + Headers []string + Methods []string + Credentials bool +} + +type SMTPConfig struct { + Host string + Port int + Username string + Password string + From string +} + +func Load() (*Config, error) { + if err := godotenv.Load(); err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("load .env: %w", err) + } + + cfg := &Config{ + AppName: envOrDefault("APP_NAME", "trustcontact"), + Env: envOrDefault("APP_ENV", EnvDevelop), + Port: envOrDefault("APP_PORT", "3000"), + BaseURL: envOrDefault("APP_BASE_URL", "http://localhost:3000"), + BuildHash: envOrDefault("APP_BUILD_HASH", "dev"), + DBDriver: envOrDefault("DB_DRIVER", DBDriverSQLite), + SQLitePath: envOrDefault("DB_SQLITE_PATH", "data/app.sqlite3"), + PostgresDSN: strings.TrimSpace(os.Getenv("DB_POSTGRES_DSN")), + CORS: CORSConfig{ + Origins: envListOrDefault("CORS_ORIGINS", []string{"http://localhost:3000"}), + Headers: envListOrDefault("CORS_HEADERS", []string{"Origin", "Content-Type", "Accept", "Authorization", "HX-Request"}), + Methods: envListOrDefault("CORS_METHODS", []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}), + Credentials: envBoolOrDefault("CORS_CREDENTIALS", true), + }, + SessionKey: envOrDefault("SESSION_KEY", "change-me-in-prod"), + EmailSinkDir: envOrDefault("EMAIL_SINK_DIR", "data/email-sink"), + AutoMigrate: envBoolOrDefault("AUTO_MIGRATE", true), + SeedEnabled: envBoolOrDefault("SEED_ENABLED", false), + SMTP: SMTPConfig{ + Host: envOrDefault("SMTP_HOST", "localhost"), + Port: envIntOrDefault("SMTP_PORT", 1025), + Username: strings.TrimSpace(os.Getenv("SMTP_USERNAME")), + Password: strings.TrimSpace(os.Getenv("SMTP_PASSWORD")), + From: envOrDefault("SMTP_FROM", "noreply@example.test"), + }, + } + + if err := cfg.Validate(); err != nil { + return nil, err + } + + return cfg, nil +} + +func (c *Config) Validate() error { + if c.AppName == "" { + return errors.New("APP_NAME is required") + } + + switch c.Env { + case EnvDevelop, EnvProd: + default: + return fmt.Errorf("APP_ENV must be one of [%s,%s]", EnvDevelop, EnvProd) + } + + if strings.TrimSpace(c.Port) == "" { + return errors.New("APP_PORT is required") + } + + switch c.DBDriver { + case DBDriverSQLite: + if strings.TrimSpace(c.SQLitePath) == "" { + return errors.New("DB_SQLITE_PATH is required when DB_DRIVER=sqlite") + } + case DBDriverPostgres: + if strings.TrimSpace(c.PostgresDSN) == "" { + return errors.New("DB_POSTGRES_DSN is required when DB_DRIVER=postgres") + } + default: + return fmt.Errorf("DB_DRIVER must be one of [%s,%s]", DBDriverSQLite, DBDriverPostgres) + } + + if strings.TrimSpace(c.SessionKey) == "" { + return errors.New("SESSION_KEY is required") + } + + if c.SMTP.Port <= 0 { + return errors.New("SMTP_PORT must be > 0") + } + + if strings.TrimSpace(c.EmailSinkDir) == "" { + return errors.New("EMAIL_SINK_DIR is required") + } + + return nil +} + +func envOrDefault(key, fallback string) string { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + return value +} + +func envBoolOrDefault(key string, fallback bool) bool { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + + parsed, err := strconv.ParseBool(value) + if err != nil { + return fallback + } + + return parsed +} + +func envIntOrDefault(key string, fallback int) int { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + + parsed, err := strconv.Atoi(value) + if err != nil { + return fallback + } + + return parsed +} + +func envListOrDefault(key string, fallback []string) []string { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + + parts := strings.Split(value, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + clean := strings.TrimSpace(part) + if clean != "" { + out = append(out, clean) + } + } + + if len(out) == 0 { + return fallback + } + + return out +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..8fa3db2 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,57 @@ +package db + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "trustcontact/internal/config" + + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +func Open(cfg *config.Config) (*gorm.DB, error) { + if cfg == nil { + return nil, fmt.Errorf("config is nil") + } + + gormCfg := &gorm.Config{Logger: newLogger(cfg.Env)} + + switch cfg.DBDriver { + case config.DBDriverSQLite: + if err := ensureSQLiteDir(cfg.SQLitePath); err != nil { + return nil, fmt.Errorf("prepare sqlite dir: %w", err) + } + return gorm.Open(sqlite.Open(cfg.SQLitePath), gormCfg) + case config.DBDriverPostgres: + return gorm.Open(postgres.Open(cfg.PostgresDSN), gormCfg) + default: + return nil, fmt.Errorf("unsupported db driver: %s", cfg.DBDriver) + } +} + +func newLogger(env string) gormlogger.Interface { + level := gormlogger.Warn + if env == config.EnvDevelop { + level = gormlogger.Info + } + + return gormlogger.Default.LogMode(level) +} + +func ensureSQLiteDir(sqlitePath string) error { + dir := filepath.Dir(sqlitePath) + if dir == "." || dir == "" { + return nil + } + + return os.MkdirAll(dir, 0o755) +} + +func nowUTC() time.Time { + return time.Now().UTC() +} diff --git a/internal/db/migrate.go b/internal/db/migrate.go new file mode 100644 index 0000000..318c5fd --- /dev/null +++ b/internal/db/migrate.go @@ -0,0 +1,15 @@ +package db + +import ( + "trustcontact/internal/models" + + "gorm.io/gorm" +) + +func Migrate(database *gorm.DB) error { + return database.AutoMigrate( + &models.User{}, + &models.EmailVerificationToken{}, + &models.PasswordResetToken{}, + ) +} diff --git a/internal/db/seed.go b/internal/db/seed.go new file mode 100644 index 0000000..8a68a9f --- /dev/null +++ b/internal/db/seed.go @@ -0,0 +1,76 @@ +package db + +import ( + "fmt" + + "trustcontact/internal/auth" + "trustcontact/internal/models" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func Seed(database *gorm.DB) error { + passwordHash, err := auth.HashPassword("password") + if err != nil { + return fmt.Errorf("hash seed password: %w", err) + } + + seedUsers := []models.User{ + { + Email: "admin@example.com", + Role: models.RoleAdmin, + EmailVerified: true, + PasswordHash: passwordHash, + }, + { + Email: "user@example.com", + Role: models.RoleUser, + EmailVerified: true, + PasswordHash: passwordHash, + }, + { + Email: "demo1@example.com", + Role: models.RoleUser, + EmailVerified: true, + PasswordHash: passwordHash, + }, + { + Email: "demo2@example.com", + Role: models.RoleUser, + EmailVerified: true, + PasswordHash: passwordHash, + }, + { + Email: "demo3@example.com", + Role: models.RoleUser, + EmailVerified: true, + PasswordHash: passwordHash, + }, + } + + for _, user := range seedUsers { + if err := upsertUser(database, user); err != nil { + return err + } + } + + return nil +} + +func upsertUser(database *gorm.DB, user models.User) error { + result := database.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "email"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "role", + "email_verified", + "password_hash", + "updated_at", + }), + }).Create(&user) + if result.Error != nil { + return fmt.Errorf("seed user %s: %w", user.Email, result.Error) + } + + return nil +} diff --git a/internal/http/router.go b/internal/http/router.go new file mode 100644 index 0000000..49907bd --- /dev/null +++ b/internal/http/router.go @@ -0,0 +1,15 @@ +package http + +import ( + "trustcontact/internal/config" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/session" + "gorm.io/gorm" +) + +func RegisterRoutes(app *fiber.App, _ *session.Store, _ *gorm.DB, _ *config.Config) { + app.Get("/healthz", func(c *fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) +} diff --git a/internal/models/auth_tokens.go b/internal/models/auth_tokens.go new file mode 100644 index 0000000..21e78a2 --- /dev/null +++ b/internal/models/auth_tokens.go @@ -0,0 +1,21 @@ +package models + +import "time" + +type EmailVerificationToken struct { + ID uint `gorm:"primaryKey"` + UserID uint `gorm:"not null;index"` + TokenHash string `gorm:"size:64;uniqueIndex;not null"` + ExpiresAt time.Time `gorm:"not null;index"` + CreatedAt time.Time + UpdatedAt time.Time +} + +type PasswordResetToken struct { + ID uint `gorm:"primaryKey"` + UserID uint `gorm:"not null;index"` + TokenHash string `gorm:"size:64;uniqueIndex;not null"` + ExpiresAt time.Time `gorm:"not null;index"` + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..5d1ecaf --- /dev/null +++ b/internal/models/user.go @@ -0,0 +1,18 @@ +package models + +import "time" + +const ( + RoleAdmin = "admin" + RoleUser = "user" +) + +type User struct { + ID uint `gorm:"primaryKey"` + Email string `gorm:"size:320;uniqueIndex;not null"` + PasswordHash string `gorm:"size:255;not null"` + EmailVerified bool `gorm:"not null;default:false"` + Role string `gorm:"size:32;index;not null;default:user"` + CreatedAt time.Time + UpdatedAt time.Time +}