Welcome {{.Email}}
++ Back to homepage +
+diff --git a/controller.go b/controller.go new file mode 100644 index 0000000..96d7ddd --- /dev/null +++ b/controller.go @@ -0,0 +1,160 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "html/template" + "path/filepath" + "strings" + "time" + + "github.com/gofiber/fiber/v3" + "gorm.io/gorm" + "prada.ch/controllers" +) + +type controller struct { + db *gorm.DB + mailConfig smtpConfig + authController *controllers.AuthController + subscribeController *controllers.SubscribeController + assetVersion string + landingTemplate *template.Template + howToWorkTemplate *template.Template + loginTemplate *template.Template + signupTemplate *template.Template + forgotPasswordTemplate *template.Template + resetPasswordTemplate *template.Template + welcomeTemplate *template.Template +} + +const sessionCookieName = "bag_exchange_session" + +func (h *controller) staticFile(c fiber.Ctx) error { + relativePath := filepath.Clean(c.Params("*")) + if relativePath == "." || strings.HasPrefix(relativePath, "..") { + return c.SendStatus(fiber.StatusNotFound) + } + return c.SendFile(filepath.Join("static", relativePath)) +} + +func (h *controller) home(c fiber.Ctx) error { + data := landingData{ + Brand: "Bag Exchange", + InitialTitle: "Bag Exchange | Swap bags and handbags", + FooterText: "© 2026 Bag Exchange · Bag and handbag exchange between individuals", + AssetVersion: h.assetVersion, + } + + var out bytes.Buffer + if err := h.landingTemplate.Execute(&out, data); err != nil { + return c.Status(fiber.StatusInternalServerError).SendString("unable to render page") + } + + c.Type("html", "utf-8") + return c.SendString(out.String()) +} + +func (h *controller) resetPasswordPage(c fiber.Ctx) error { + token := strings.TrimSpace(c.Query("token")) + var out bytes.Buffer + if err := h.resetPasswordTemplate.Execute(&out, resetPasswordPageData{Token: token}); err != nil { + return c.Status(fiber.StatusInternalServerError).SendString("unable to render reset password page") + } + c.Type("html", "utf-8") + return c.SendString(out.String()) +} + +func (h *controller) howToWorkPage(c fiber.Ctx) error { + var out bytes.Buffer + if err := h.howToWorkTemplate.Execute(&out, nil); err != nil { + return c.Status(fiber.StatusInternalServerError).SendString("unable to render howtowork page") + } + c.Type("html", "utf-8") + return c.SendString(out.String()) +} + +func (h *controller) loginPage(c fiber.Ctx) error { + var out bytes.Buffer + if err := h.loginTemplate.Execute(&out, nil); err != nil { + return c.Status(fiber.StatusInternalServerError).SendString("unable to render login page") + } + c.Type("html", "utf-8") + return c.SendString(out.String()) +} + +func (h *controller) signupPage(c fiber.Ctx) error { + var out bytes.Buffer + if err := h.signupTemplate.Execute(&out, nil); err != nil { + return c.Status(fiber.StatusInternalServerError).SendString("unable to render signup page") + } + c.Type("html", "utf-8") + return c.SendString(out.String()) +} + +func (h *controller) forgotPasswordPage(c fiber.Ctx) error { + var out bytes.Buffer + if err := h.forgotPasswordTemplate.Execute(&out, nil); err != nil { + return c.Status(fiber.StatusInternalServerError).SendString("unable to render forgot password page") + } + c.Type("html", "utf-8") + return c.SendString(out.String()) +} + +func (h *controller) welcomePage(c fiber.Ctx) error { + email, ok := h.currentUserEmail(c) + if !ok { + return c.Redirect().To("/login") + } + + var out bytes.Buffer + if err := h.welcomeTemplate.Execute(&out, map[string]string{"Email": email}); err != nil { + return c.Status(fiber.StatusInternalServerError).SendString("unable to render welcome page") + } + c.Type("html", "utf-8") + return c.SendString(out.String()) +} + +func (h *controller) currentUserEmail(c fiber.Ctx) (string, bool) { + sessionToken := c.Cookies(sessionCookieName) + if sessionToken == "" { + return "", false + } + + var sessionUser struct { + Email string `gorm:"column:email"` + } + err := h.db.Table("sessions"). + Select("users.email"). + Joins("JOIN users ON users.id = sessions.user_id"). + Where("sessions.token_hash = ? AND sessions.expires_at > ?", hashSessionToken(sessionToken), time.Now().Unix()). + Take(&sessionUser).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + clearAuthSessionCookie(c) + return "", false + } + return "", false + } + return sessionUser.Email, true +} + +func hashSessionToken(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + +func clearAuthSessionCookie(c fiber.Ctx) { + c.Cookie(&fiber.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + HTTPOnly: true, + Secure: false, + SameSite: "Lax", + Expires: time.Unix(0, 0), + MaxAge: -1, + }) +} diff --git a/controllers/auth_controller.go b/controllers/auth_controller.go new file mode 100644 index 0000000..1a258a7 --- /dev/null +++ b/controllers/auth_controller.go @@ -0,0 +1,520 @@ +package controllers + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log" + "net/mail" + "strings" + "time" + "unicode" + + "github.com/gofiber/fiber/v3" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type AuthController struct { + DB *gorm.DB + IsUniqueConstraint func(error) bool + AppBaseURL func() string + MailConfigured func() bool + SendPasswordResetEmail func(recipientEmail, resetURL string) error + SendEmailVerificationMail func(recipientEmail, verifyURL string) error +} + +type registerRequest struct { + Email string `json:"email"` + Password string `json:"password"` + ConfirmPassword string `json:"confirmPassword"` +} + +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type forgotPasswordRequest struct { + Email string `json:"email"` +} + +type resetPasswordRequest struct { + Token string `json:"token"` + Password string `json:"password"` + ConfirmPassword string `json:"confirmPassword"` +} + +type resendVerificationRequest struct { + Email string `json:"email"` +} + +const sessionCookieName = "bag_exchange_session" +const sessionDurationDays = 7 +const passwordResetDurationMinutes = 60 +const emailVerificationDurationHours = 24 + +func (a *AuthController) Register(c fiber.Ctx) error { + var req registerRequest + if err := json.Unmarshal(c.Body(), &req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_payload"}) + } + + email := strings.ToLower(strings.TrimSpace(req.Email)) + if _, err := mail.ParseAddress(email); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_email"}) + } + + if req.Password != req.ConfirmPassword { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "password_mismatch"}) + } + + if !isStrongPassword(req.Password) { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "password_too_weak"}) + } + + passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + log.Printf("unable to hash password: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_register"}) + } + + err = a.DB.Table("users").Create(map[string]any{ + "email": email, + "password_hash": string(passwordHash), + "email_verified": 0, + }).Error + if err != nil { + if a.IsUniqueConstraint != nil && a.IsUniqueConstraint(err) { + return c.Status(fiber.StatusConflict).JSON(map[string]string{"error": "email_exists"}) + } + log.Printf("unable to register user: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_register"}) + } + + var createdUser struct { + ID int64 `gorm:"column:id"` + } + err = a.DB.Table("users").Select("id").Where("email = ?", email).Take(&createdUser).Error + if err != nil { + log.Printf("unable to read new user id: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_register"}) + } + userID := createdUser.ID + + verifyToken, err := createEmailVerificationToken(a.DB, userID) + if err != nil { + log.Printf("unable to create verify token: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_register"}) + } + + if a.MailConfigured != nil && a.MailConfigured() && a.AppBaseURL != nil && a.SendEmailVerificationMail != nil { + verifyURL := fmt.Sprintf("%s/auth/verify-email?token=%s", a.AppBaseURL(), verifyToken) + if err := a.SendEmailVerificationMail(email, verifyURL); err != nil { + log.Printf("unable to send verification email: %v", err) + } + } + + return c.Status(fiber.StatusCreated).JSON(map[string]string{"status": "registered_pending_verification"}) +} + +func (a *AuthController) Login(c fiber.Ctx) error { + var req loginRequest + if err := json.Unmarshal(c.Body(), &req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_payload"}) + } + + email := strings.ToLower(strings.TrimSpace(req.Email)) + if _, err := mail.ParseAddress(email); err != nil || req.Password == "" { + return c.Status(fiber.StatusUnauthorized).JSON(map[string]string{"error": "invalid_credentials"}) + } + + var loginUser struct { + ID int64 `gorm:"column:id"` + PasswordHash string `gorm:"column:password_hash"` + EmailVerified int `gorm:"column:email_verified"` + } + err := a.DB.Table("users"). + Select("id, password_hash, email_verified"). + Where("email = ?", email). + Take(&loginUser).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(fiber.StatusUnauthorized).JSON(map[string]string{"error": "invalid_credentials"}) + } + log.Printf("unable to fetch user for login: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_login"}) + } + userID := loginUser.ID + passwordHash := loginUser.PasswordHash + emailVerified := loginUser.EmailVerified + + if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(map[string]string{"error": "invalid_credentials"}) + } + if emailVerified == 0 { + return c.Status(fiber.StatusForbidden).JSON(map[string]string{"error": "email_not_verified"}) + } + + sessionToken, err := generateSessionToken() + if err != nil { + log.Printf("unable to generate session token: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_login"}) + } + + expiresAt := time.Now().AddDate(0, 0, sessionDurationDays).Unix() + err = a.DB.Table("sessions").Create(map[string]any{ + "user_id": userID, + "token_hash": hashToken(sessionToken), + "expires_at": expiresAt, + }).Error + if err != nil { + log.Printf("unable to create session: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_login"}) + } + + setSessionCookie(c, sessionToken, expiresAt) + return c.Status(fiber.StatusOK).JSON(map[string]any{ + "status": "authenticated", + "email": email, + }) +} + +func (a *AuthController) Logout(c fiber.Ctx) error { + sessionToken := c.Cookies(sessionCookieName) + if sessionToken != "" { + if err := a.DB.Table("sessions").Where("token_hash = ?", hashToken(sessionToken)).Delete(nil).Error; err != nil { + log.Printf("unable to delete session: %v", err) + } + } + clearSessionCookie(c) + return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "logged_out"}) +} + +func (a *AuthController) Me(c fiber.Ctx) error { + sessionToken := c.Cookies(sessionCookieName) + if sessionToken == "" { + return c.Status(fiber.StatusUnauthorized).JSON(map[string]any{"authenticated": false}) + } + + var sessionUser struct { + UserID int64 `gorm:"column:user_id"` + Email string `gorm:"column:email"` + } + err := a.DB.Table("sessions"). + Select("users.id AS user_id, users.email"). + Joins("JOIN users ON users.id = sessions.user_id"). + Where("sessions.token_hash = ? AND sessions.expires_at > ?", hashToken(sessionToken), time.Now().Unix()). + Take(&sessionUser).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + clearSessionCookie(c) + return c.Status(fiber.StatusUnauthorized).JSON(map[string]any{"authenticated": false}) + } + log.Printf("unable to resolve session: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_fetch_session"}) + } + + return c.Status(fiber.StatusOK).JSON(map[string]any{ + "authenticated": true, + "userId": sessionUser.UserID, + "email": sessionUser.Email, + }) +} + +func (a *AuthController) ForgotPassword(c fiber.Ctx) error { + var req forgotPasswordRequest + if err := json.Unmarshal(c.Body(), &req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_payload"}) + } + + email := strings.ToLower(strings.TrimSpace(req.Email)) + if _, err := mail.ParseAddress(email); err != nil { + return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"}) + } + + var userID int64 + err := a.DB.Table("users").Select("id").Where("email = ?", email).Take(&userID).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"}) + } + log.Printf("unable to fetch user for forgot-password: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_process"}) + } + + resetToken, err := generateSessionToken() + if err != nil { + log.Printf("unable to generate reset token: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_process"}) + } + expiresAt := time.Now().Add(time.Minute * passwordResetDurationMinutes).Unix() + + err = a.DB.Table("password_reset_tokens").Create(map[string]any{ + "user_id": userID, + "token_hash": hashToken(resetToken), + "expires_at": expiresAt, + }).Error + if err != nil { + log.Printf("unable to persist reset token: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_process"}) + } + + if a.MailConfigured != nil && a.MailConfigured() && a.AppBaseURL != nil && a.SendPasswordResetEmail != nil { + resetURL := fmt.Sprintf("%s/reset-password?token=%s", a.AppBaseURL(), resetToken) + if err := a.SendPasswordResetEmail(email, resetURL); err != nil { + log.Printf("unable to send reset email: %v", err) + } + } else { + log.Printf("smtp not configured: skip password reset email") + } + + return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"}) +} + +func (a *AuthController) ResendVerification(c fiber.Ctx) error { + var req resendVerificationRequest + if err := json.Unmarshal(c.Body(), &req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_payload"}) + } + + email := strings.ToLower(strings.TrimSpace(req.Email)) + if _, err := mail.ParseAddress(email); err != nil { + return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"}) + } + + var resendUser struct { + ID int64 `gorm:"column:id"` + EmailVerified int `gorm:"column:email_verified"` + } + err := a.DB.Table("users").Select("id, email_verified").Where("email = ?", email).Take(&resendUser).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"}) + } + log.Printf("unable to fetch user for resend verification: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_process"}) + } + userID := resendUser.ID + verified := resendUser.EmailVerified + if verified != 0 { + return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"}) + } + + verifyToken, err := createEmailVerificationToken(a.DB, userID) + if err != nil { + log.Printf("unable to create verify token on resend: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_process"}) + } + if a.MailConfigured != nil && a.MailConfigured() && a.AppBaseURL != nil && a.SendEmailVerificationMail != nil { + verifyURL := fmt.Sprintf("%s/auth/verify-email?token=%s", a.AppBaseURL(), verifyToken) + if err := a.SendEmailVerificationMail(email, verifyURL); err != nil { + log.Printf("unable to resend verification email: %v", err) + } + } + return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"}) +} + +func (a *AuthController) ResetPassword(c fiber.Ctx) error { + var req resetPasswordRequest + if err := json.Unmarshal(c.Body(), &req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_payload"}) + } + + token := strings.TrimSpace(req.Token) + if token == "" { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_token"}) + } + + if req.Password != req.ConfirmPassword { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "password_mismatch"}) + } + + if !isStrongPassword(req.Password) { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "password_too_weak"}) + } + + var resetTokenRow struct { + ID int64 `gorm:"column:id"` + UserID int64 `gorm:"column:user_id"` + } + err := a.DB.Table("password_reset_tokens"). + Select("id, user_id"). + Where("token_hash = ? AND used_at IS NULL AND expires_at > ?", hashToken(token), time.Now().Unix()). + Order("id DESC"). + Take(&resetTokenRow).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_or_expired_token"}) + } + log.Printf("unable to fetch reset token: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"}) + } + resetID := resetTokenRow.ID + userID := resetTokenRow.UserID + + passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + log.Printf("unable to hash password in reset: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"}) + } + + tx := a.DB.Begin() + if tx.Error != nil { + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"}) + } + + if err := tx.Table("users").Where("id = ?", userID).Updates(map[string]any{ + "password_hash": string(passwordHash), + "updated_at": gorm.Expr("datetime('now')"), + }).Error; err != nil { + _ = tx.Rollback().Error + log.Printf("unable to update password: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"}) + } + if err := tx.Table("password_reset_tokens").Where("id = ?", resetID).Update("used_at", time.Now().Unix()).Error; err != nil { + _ = tx.Rollback().Error + log.Printf("unable to mark reset token used: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"}) + } + if err := tx.Table("sessions").Where("user_id = ?", userID).Delete(nil).Error; err != nil { + _ = tx.Rollback().Error + log.Printf("unable to delete sessions after reset: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"}) + } + if err := tx.Commit().Error; err != nil { + log.Printf("unable to commit reset tx: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"}) + } + + clearSessionCookie(c) + return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "password_reset"}) +} + +func (a *AuthController) VerifyEmail(c fiber.Ctx) error { + token := strings.TrimSpace(c.Query("token")) + if token == "" { + return c.Status(fiber.StatusBadRequest).SendString("Invalid verification token.") + } + + var verifyTokenRow struct { + ID int64 `gorm:"column:id"` + UserID int64 `gorm:"column:user_id"` + } + err := a.DB.Table("email_verification_tokens"). + Select("id, user_id"). + Where("token_hash = ? AND used_at IS NULL AND expires_at > ?", hashToken(token), time.Now().Unix()). + Order("id DESC"). + Take(&verifyTokenRow).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(fiber.StatusBadRequest).SendString("Verification link is invalid or expired.") + } + log.Printf("unable to validate verification token: %v", err) + return c.Status(fiber.StatusInternalServerError).SendString("Unable to verify email right now.") + } + tokenID := verifyTokenRow.ID + userID := verifyTokenRow.UserID + + tx := a.DB.Begin() + if tx.Error != nil { + return c.Status(fiber.StatusInternalServerError).SendString("Unable to verify email right now.") + } + if err := tx.Table("users").Where("id = ?", userID).Updates(map[string]any{ + "email_verified": 1, + "updated_at": gorm.Expr("datetime('now')"), + }).Error; err != nil { + _ = tx.Rollback().Error + log.Printf("unable to mark email verified: %v", err) + return c.Status(fiber.StatusInternalServerError).SendString("Unable to verify email right now.") + } + if err := tx.Table("email_verification_tokens").Where("id = ?", tokenID).Update("used_at", time.Now().Unix()).Error; err != nil { + _ = tx.Rollback().Error + log.Printf("unable to mark verify token used: %v", err) + return c.Status(fiber.StatusInternalServerError).SendString("Unable to verify email right now.") + } + if err := tx.Commit().Error; err != nil { + log.Printf("unable to commit verify email tx: %v", err) + return c.Status(fiber.StatusInternalServerError).SendString("Unable to verify email right now.") + } + + return c.SendString("Email verified successfully. You can now log in.") +} + +func setSessionCookie(c fiber.Ctx, value string, expiresAtUnix int64) { + c.Cookie(&fiber.Cookie{ + Name: sessionCookieName, + Value: value, + Path: "/", + HTTPOnly: true, + Secure: false, + SameSite: "Lax", + Expires: time.Unix(expiresAtUnix, 0), + MaxAge: 60 * 60 * 24 * sessionDurationDays, + }) +} + +func clearSessionCookie(c fiber.Ctx) { + c.Cookie(&fiber.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + HTTPOnly: true, + Secure: false, + SameSite: "Lax", + Expires: time.Unix(0, 0), + MaxAge: -1, + }) +} + +func hashToken(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + +func generateSessionToken() (string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +func isStrongPassword(value string) bool { + if len(value) < 8 { + return false + } + + hasLetter := false + hasDigit := false + for _, r := range value { + if unicode.IsLetter(r) { + hasLetter = true + } + if unicode.IsDigit(r) { + hasDigit = true + } + } + return hasLetter && hasDigit +} + +func createEmailVerificationToken(db *gorm.DB, userID int64) (string, error) { + token, err := generateSessionToken() + if err != nil { + return "", err + } + expiresAt := time.Now().Add(time.Hour * emailVerificationDurationHours).Unix() + err = db.Table("email_verification_tokens").Create(map[string]any{ + "user_id": userID, + "token_hash": hashToken(token), + "expires_at": expiresAt, + }).Error + if err != nil { + return "", err + } + return token, nil +} diff --git a/controllers/subscribe_controller.go b/controllers/subscribe_controller.go new file mode 100644 index 0000000..ded4fe6 --- /dev/null +++ b/controllers/subscribe_controller.go @@ -0,0 +1,88 @@ +package controllers + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "log" + "net/mail" + "strings" + "time" + + "github.com/gofiber/fiber/v3" + "gorm.io/gorm" +) + +type SubscribeController struct { + DB *gorm.DB + IsUniqueConstraint func(error) bool +} + +type subscribeRequest struct { + Email string `json:"email"` + BrowserData map[string]any `json:"browserData"` +} + +const subscriptionCookieName = "bag_exchange_subscribed" + +func (s *SubscribeController) Subscribe(c fiber.Ctx) error { + var req subscribeRequest + if err := json.Unmarshal(c.Body(), &req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid payload"}) + } + + email := strings.ToLower(strings.TrimSpace(req.Email)) + if _, err := mail.ParseAddress(email); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid email"}) + } + + emailHash := hashEmail(email) + if c.Cookies(subscriptionCookieName) == emailHash { + return c.Status(fiber.StatusConflict).JSON(map[string]string{"status": "already_submitted"}) + } + + browserData := "{}" + if len(req.BrowserData) > 0 { + payload, err := json.Marshal(req.BrowserData) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid browser data"}) + } + browserData = string(payload) + } + + err := s.DB.Table("subscribers").Create(map[string]any{ + "email": email, + "ip_address": c.IP(), + "user_agent": c.Get("User-Agent"), + "accept_language": c.Get("Accept-Language"), + "browser_data": browserData, + }).Error + if err != nil { + if s.IsUniqueConstraint != nil && s.IsUniqueConstraint(err) { + setSubscriptionCookie(c, emailHash) + return c.Status(fiber.StatusConflict).JSON(map[string]string{"status": "already_submitted"}) + } + log.Printf("unable to insert subscriber: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable to save subscription"}) + } + + setSubscriptionCookie(c, emailHash) + return c.Status(fiber.StatusCreated).JSON(map[string]string{"status": "subscribed"}) +} + +func hashEmail(email string) string { + sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(email)))) + return hex.EncodeToString(sum[:]) +} + +func setSubscriptionCookie(c fiber.Ctx, value string) { + c.Cookie(&fiber.Cookie{ + Name: subscriptionCookieName, + Value: value, + Path: "/", + HTTPOnly: false, + Secure: false, + Expires: time.Now().AddDate(1, 0, 0), + MaxAge: 60 * 60 * 24 * 365, + }) +} diff --git a/controllers/user_conntroller.go b/controllers/user_conntroller.go new file mode 100644 index 0000000..3884895 --- /dev/null +++ b/controllers/user_conntroller.go @@ -0,0 +1,7 @@ +package controllers + +import "gorm.io/gorm" + +type UserConntroller struct { + DB *gorm.DB +} diff --git a/data/subscribers.db b/data/subscribers.db index 0da6dc2..8b0a9a1 100644 Binary files a/data/subscribers.db and b/data/subscribers.db differ diff --git a/go.mod b/go.mod index 4a1e8bd..75a6e73 100644 --- a/go.mod +++ b/go.mod @@ -2,29 +2,29 @@ module prada.ch go 1.25.4 -require github.com/gofiber/fiber/v3 v3.0.0 +require ( + github.com/gofiber/fiber/v3 v3.0.0 + golang.org/x/crypto v0.47.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 +) require ( github.com/andybalholm/brotli v1.2.0 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/gofiber/schema v1.6.0 // indirect github.com/gofiber/utils/v2 v2.0.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/klauspost/compress v1.18.3 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/philhofer/fwd v1.2.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/tinylib/msgp v1.6.3 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect - golang.org/x/crypto v0.47.0 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect - modernc.org/libc v1.55.3 // indirect - modernc.org/mathutil v1.6.0 // indirect - modernc.org/memory v1.8.0 // indirect - modernc.org/sqlite v1.34.5 // indirect ) diff --git a/go.sum b/go.sum index 7124363..a380706 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,6 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= 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/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/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gofiber/fiber/v3 v3.0.0 h1:GPeCG8X60L42wLKrzgeewDHBr6pE6veAvwaXsqD3Xjk= @@ -14,20 +12,22 @@ github.com/gofiber/utils/v2 v2.0.0 h1:SCC3rpsEDWupFSHtc0RKxg/BKgV0s1qKfZg9Jv6D0s github.com/gofiber/utils/v2 v2.0.0/go.mod h1:xF9v89FfmbrYqI/bQUGN7gR8ZtXot2jxnZvmAUtiavE= 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/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= 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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +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/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/shamaton/msgpack/v3 v3.0.0 h1:xl40uxWkSpwBCSTvS5wyXvJRsC6AcVcYeox9PspKiZg= github.com/shamaton/msgpack/v3 v3.0.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -53,11 +53,7 @@ golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= -modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= -modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= -modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= -modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= -modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= -modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= -modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= +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/main.go b/main.go index 75fbbe6..657c145 100644 --- a/main.go +++ b/main.go @@ -2,29 +2,18 @@ package main import ( "bufio" - "bytes" - "crypto/rand" "crypto/sha256" - "database/sql" - "encoding/base64" "encoding/hex" - "encoding/json" - "errors" - "fmt" "html/template" "log" - "net/mail" - "net/smtp" "os" "path/filepath" - "strconv" "strings" - "time" - "unicode" "github.com/gofiber/fiber/v3" - "golang.org/x/crypto/bcrypt" - _ "modernc.org/sqlite" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "prada.ch/controllers" ) type landingData struct { @@ -38,49 +27,6 @@ type resetPasswordPageData struct { Token string } -type subscribeRequest struct { - Email string `json:"email"` - BrowserData map[string]any `json:"browserData"` -} - -type registerRequest struct { - Email string `json:"email"` - Password string `json:"password"` - ConfirmPassword string `json:"confirmPassword"` -} - -type loginRequest struct { - Email string `json:"email"` - Password string `json:"password"` -} - -type forgotPasswordRequest struct { - Email string `json:"email"` -} - -type resetPasswordRequest struct { - Token string `json:"token"` - Password string `json:"password"` - ConfirmPassword string `json:"confirmPassword"` -} - -type resendVerificationRequest struct { - Email string `json:"email"` -} - -type smtpConfig struct { - Host string - Port int - User string - Password string -} - -const subscriptionCookieName = "bag_exchange_subscribed" -const sessionCookieName = "bag_exchange_session" -const sessionDurationDays = 7 -const passwordResetDurationMinutes = 60 -const emailVerificationDurationHours = 24 - func main() { loadDotEnv(".env") @@ -88,484 +34,61 @@ func main() { if err != nil { log.Fatal(err) } - defer db.Close() + sqlDB, err := db.DB() + if err != nil { + log.Fatal(err) + } + defer sqlDB.Close() - mailConfig := getSMTPConfigFromEnv() - - landingTemplate := template.Must(template.ParseFiles("templates/index.html")) - howToWorkTemplate := template.Must(template.ParseFiles("templates/howtowork.html")) - loginTemplate := template.Must(template.ParseFiles("templates/login.html")) - signupTemplate := template.Must(template.ParseFiles("templates/signup.html")) - forgotPasswordTemplate := template.Must(template.ParseFiles("templates/forgot_password.html")) - resetPasswordTemplate := template.Must(template.ParseFiles("templates/reset_password.html")) - assetVersion := buildAssetVersion("static/css/main.css", "static/js/i18n.js") + h := &controller{ + db: db, + mailConfig: getSMTPConfigFromEnv(), + landingTemplate: template.Must(template.ParseFiles("templates/public/index.html")), + howToWorkTemplate: template.Must(template.ParseFiles("templates/public/howtowork.html")), + loginTemplate: template.Must(template.ParseFiles("templates/public/login.html")), + signupTemplate: template.Must(template.ParseFiles("templates/public/signup.html")), + forgotPasswordTemplate: template.Must(template.ParseFiles("templates/public/forgot_password.html")), + resetPasswordTemplate: template.Must(template.ParseFiles("templates/public/reset_password.html")), + welcomeTemplate: template.Must(template.ParseFiles("templates/private/welcome.html")), + assetVersion: buildAssetVersion("static/css/main.css", "static/js/i18n.js"), + } + h.subscribeController = &controllers.SubscribeController{ + DB: h.db, + IsUniqueConstraint: isUniqueConstraint, + } + h.authController = &controllers.AuthController{ + DB: h.db, + IsUniqueConstraint: isUniqueConstraint, + AppBaseURL: getAppBaseURL, + MailConfigured: h.mailConfig.isConfigured, + SendPasswordResetEmail: func(recipientEmail, resetURL string) error { + return sendPasswordResetEmail(h.mailConfig, recipientEmail, resetURL) + }, + SendEmailVerificationMail: func(recipientEmail, verifyURL string) error { + return sendEmailVerificationEmail(h.mailConfig, recipientEmail, verifyURL) + }, + } app := fiber.New() - app.Get("/static/*", func(c fiber.Ctx) error { - relativePath := filepath.Clean(c.Params("*")) - if relativePath == "." || strings.HasPrefix(relativePath, "..") { - return c.SendStatus(fiber.StatusNotFound) - } - return c.SendFile(filepath.Join("static", relativePath)) - }) - app.Post("/api/subscribe", func(c fiber.Ctx) error { - var req subscribeRequest - if err := json.Unmarshal(c.Body(), &req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid payload"}) - } + app.Get("/static/*", h.staticFile) + app.Post("/api/subscribe", h.subscribeController.Subscribe) + app.Post("/api/auth/register", h.authController.Register) + app.Post("/api/auth/login", h.authController.Login) + app.Post("/api/auth/logout", h.authController.Logout) + app.Get("/api/auth/me", h.authController.Me) + app.Post("/api/auth/forgot-password", h.authController.ForgotPassword) + app.Post("/api/auth/resend-verification", h.authController.ResendVerification) + app.Post("/api/auth/reset-password", h.authController.ResetPassword) + app.Get("/auth/verify-email", h.authController.VerifyEmail) + app.Get("/", h.home) + app.Get("/reset-password", h.resetPasswordPage) + app.Get("/howtowork", h.howToWorkPage) + app.Get("/login", h.loginPage) + app.Get("/signup", h.signupPage) + app.Get("/forgot-password", h.forgotPasswordPage) + app.Get("/welcome", h.welcomePage) - email := strings.ToLower(strings.TrimSpace(req.Email)) - if _, err := mail.ParseAddress(email); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid email"}) - } - - emailHash := hashEmail(email) - if c.Cookies(subscriptionCookieName) == emailHash { - return c.Status(fiber.StatusConflict).JSON(map[string]string{"status": "already_submitted"}) - } - - browserData := "{}" - if len(req.BrowserData) > 0 { - payload, err := json.Marshal(req.BrowserData) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid browser data"}) - } - browserData = string(payload) - } - - _, err = db.Exec(` - INSERT INTO subscribers (email, ip_address, user_agent, accept_language, browser_data) - VALUES (?, ?, ?, ?, ?) - `, email, c.IP(), c.Get("User-Agent"), c.Get("Accept-Language"), browserData) - if err != nil { - if isUniqueConstraint(err) { - setSubscriptionCookie(c, emailHash) - return c.Status(fiber.StatusConflict).JSON(map[string]string{"status": "already_submitted"}) - } - log.Printf("unable to insert subscriber: %v", err) - return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable to save subscription"}) - } - - setSubscriptionCookie(c, emailHash) - return c.Status(fiber.StatusCreated).JSON(map[string]string{"status": "subscribed"}) - }) - app.Post("/api/auth/register", func(c fiber.Ctx) error { - var req registerRequest - if err := json.Unmarshal(c.Body(), &req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_payload"}) - } - - email := strings.ToLower(strings.TrimSpace(req.Email)) - if _, err := mail.ParseAddress(email); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_email"}) - } - - if req.Password != req.ConfirmPassword { - return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "password_mismatch"}) - } - - if !isStrongPassword(req.Password) { - return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "password_too_weak"}) - } - - passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) - if err != nil { - log.Printf("unable to hash password: %v", err) - return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_register"}) - } - - result, err := db.Exec(` - INSERT INTO users (email, password_hash, email_verified) - VALUES (?, ?, ?) - `, email, string(passwordHash), 0) - if err != nil { - if isUniqueConstraint(err) { - return c.Status(fiber.StatusConflict).JSON(map[string]string{"error": "email_exists"}) - } - log.Printf("unable to register user: %v", err) - return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_register"}) - } - - userID, err := result.LastInsertId() - if err != nil { - log.Printf("unable to read new user id: %v", err) - return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_register"}) - } - - verifyToken, err := createEmailVerificationToken(db, userID) - if err != nil { - log.Printf("unable to create verify token: %v", err) - return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_register"}) - } - - if mailConfig.isConfigured() { - verifyURL := fmt.Sprintf("%s/auth/verify-email?token=%s", getAppBaseURL(), verifyToken) - if err := sendEmailVerificationEmail(mailConfig, email, verifyURL); err != nil { - log.Printf("unable to send verification email: %v", err) - } - } - - return c.Status(fiber.StatusCreated).JSON(map[string]string{"status": "registered_pending_verification"}) - }) - app.Post("/api/auth/login", func(c fiber.Ctx) error { - var req loginRequest - if err := json.Unmarshal(c.Body(), &req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_payload"}) - } - - email := strings.ToLower(strings.TrimSpace(req.Email)) - if _, err := mail.ParseAddress(email); err != nil || req.Password == "" { - return c.Status(fiber.StatusUnauthorized).JSON(map[string]string{"error": "invalid_credentials"}) - } - - var userID int64 - var passwordHash string - var emailVerified int - err := db.QueryRow(` - SELECT id, password_hash, email_verified - FROM users - WHERE email = ? - `, email).Scan(&userID, &passwordHash, &emailVerified) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return c.Status(fiber.StatusUnauthorized).JSON(map[string]string{"error": "invalid_credentials"}) - } - log.Printf("unable to fetch user for login: %v", err) - return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_login"}) - } - - if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil { - return c.Status(fiber.StatusUnauthorized).JSON(map[string]string{"error": "invalid_credentials"}) - } - if emailVerified == 0 { - return c.Status(fiber.StatusForbidden).JSON(map[string]string{"error": "email_not_verified"}) - } - - sessionToken, err := generateSessionToken() - if err != nil { - log.Printf("unable to generate session token: %v", err) - return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_login"}) - } - - expiresAt := time.Now().AddDate(0, 0, sessionDurationDays).Unix() - _, err = db.Exec(` - INSERT INTO sessions (user_id, token_hash, expires_at) - VALUES (?, ?, ?) - `, userID, hashToken(sessionToken), expiresAt) - if err != nil { - log.Printf("unable to create session: %v", err) - return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_login"}) - } - - setSessionCookie(c, sessionToken, expiresAt) - return c.Status(fiber.StatusOK).JSON(map[string]any{ - "status": "authenticated", - "email": email, - }) - }) - app.Post("/api/auth/logout", func(c fiber.Ctx) error { - sessionToken := c.Cookies(sessionCookieName) - if sessionToken != "" { - if _, err := db.Exec(`DELETE FROM sessions WHERE token_hash = ?`, hashToken(sessionToken)); err != nil { - log.Printf("unable to delete session: %v", err) - } - } - clearSessionCookie(c) - return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "logged_out"}) - }) - app.Get("/api/auth/me", func(c fiber.Ctx) error { - sessionToken := c.Cookies(sessionCookieName) - if sessionToken == "" { - return c.Status(fiber.StatusUnauthorized).JSON(map[string]any{"authenticated": false}) - } - - var userID int64 - var email string - err := db.QueryRow(` - SELECT users.id, users.email - FROM sessions - JOIN users ON users.id = sessions.user_id - WHERE sessions.token_hash = ? AND sessions.expires_at > ? - `, hashToken(sessionToken), time.Now().Unix()).Scan(&userID, &email) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - clearSessionCookie(c) - return c.Status(fiber.StatusUnauthorized).JSON(map[string]any{"authenticated": false}) - } - log.Printf("unable to resolve session: %v", err) - return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_fetch_session"}) - } - - return c.Status(fiber.StatusOK).JSON(map[string]any{ - "authenticated": true, - "userId": userID, - "email": email, - }) - }) - app.Post("/api/auth/forgot-password", func(c fiber.Ctx) error { - var req forgotPasswordRequest - if err := json.Unmarshal(c.Body(), &req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_payload"}) - } - - email := strings.ToLower(strings.TrimSpace(req.Email)) - if _, err := mail.ParseAddress(email); err != nil { - return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"}) - } - - var userID int64 - err := db.QueryRow(`SELECT id FROM users WHERE email = ?`, email).Scan(&userID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"}) - } - log.Printf("unable to fetch user for forgot-password: %v", err) - return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_process"}) - } - - resetToken, err := generateSessionToken() - if err != nil { - log.Printf("unable to generate reset token: %v", err) - return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_process"}) - } - expiresAt := time.Now().Add(time.Minute * passwordResetDurationMinutes).Unix() - - _, err = db.Exec(` - INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) - VALUES (?, ?, ?) - `, userID, hashToken(resetToken), expiresAt) - if err != nil { - log.Printf("unable to persist reset token: %v", err) - return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_process"}) - } - - if mailConfig.isConfigured() { - resetURL := fmt.Sprintf("%s/reset-password?token=%s", getAppBaseURL(), resetToken) - if err := sendPasswordResetEmail(mailConfig, email, resetURL); err != nil { - log.Printf("unable to send reset email: %v", err) - } - } else { - log.Printf("smtp not configured: skip password reset email") - } - - return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"}) - }) - app.Post("/api/auth/resend-verification", func(c fiber.Ctx) error { - var req resendVerificationRequest - if err := json.Unmarshal(c.Body(), &req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_payload"}) - } - - email := strings.ToLower(strings.TrimSpace(req.Email)) - if _, err := mail.ParseAddress(email); err != nil { - return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"}) - } - - var userID int64 - var verified int - err := db.QueryRow(`SELECT id, email_verified FROM users WHERE email = ?`, email).Scan(&userID, &verified) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"}) - } - log.Printf("unable to fetch user for resend verification: %v", err) - return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_process"}) - } - if verified != 0 { - return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"}) - } - - verifyToken, err := createEmailVerificationToken(db, userID) - if err != nil { - log.Printf("unable to create verify token on resend: %v", err) - return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_process"}) - } - if mailConfig.isConfigured() { - verifyURL := fmt.Sprintf("%s/auth/verify-email?token=%s", getAppBaseURL(), verifyToken) - if err := sendEmailVerificationEmail(mailConfig, email, verifyURL); err != nil { - log.Printf("unable to resend verification email: %v", err) - } - } - return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"}) - }) - app.Post("/api/auth/reset-password", func(c fiber.Ctx) error { - var req resetPasswordRequest - if err := json.Unmarshal(c.Body(), &req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_payload"}) - } - - token := strings.TrimSpace(req.Token) - if token == "" { - return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_token"}) - } - - if req.Password != req.ConfirmPassword { - return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "password_mismatch"}) - } - - if !isStrongPassword(req.Password) { - return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "password_too_weak"}) - } - - var resetID int64 - var userID int64 - err := db.QueryRow(` - SELECT id, user_id - FROM password_reset_tokens - WHERE token_hash = ? - AND used_at IS NULL - AND expires_at > ? - ORDER BY id DESC - LIMIT 1 - `, hashToken(token), time.Now().Unix()).Scan(&resetID, &userID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_or_expired_token"}) - } - log.Printf("unable to fetch reset token: %v", err) - return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"}) - } - - passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) - if err != nil { - log.Printf("unable to hash password in reset: %v", err) - return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"}) - } - - tx, err := db.Begin() - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"}) - } - - if _, err := tx.Exec(`UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?`, string(passwordHash), userID); err != nil { - tx.Rollback() - log.Printf("unable to update password: %v", err) - return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"}) - } - if _, err := tx.Exec(`UPDATE password_reset_tokens SET used_at = ? WHERE id = ?`, time.Now().Unix(), resetID); err != nil { - tx.Rollback() - log.Printf("unable to mark reset token used: %v", err) - return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"}) - } - if _, err := tx.Exec(`DELETE FROM sessions WHERE user_id = ?`, userID); err != nil { - tx.Rollback() - log.Printf("unable to delete sessions after reset: %v", err) - return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"}) - } - if err := tx.Commit(); err != nil { - log.Printf("unable to commit reset tx: %v", err) - return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"}) - } - - clearSessionCookie(c) - return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "password_reset"}) - }) - app.Get("/auth/verify-email", func(c fiber.Ctx) error { - token := strings.TrimSpace(c.Query("token")) - if token == "" { - return c.Status(fiber.StatusBadRequest).SendString("Invalid verification token.") - } - - var tokenID int64 - var userID int64 - err := db.QueryRow(` - SELECT id, user_id - FROM email_verification_tokens - WHERE token_hash = ? - AND used_at IS NULL - AND expires_at > ? - ORDER BY id DESC - LIMIT 1 - `, hashToken(token), time.Now().Unix()).Scan(&tokenID, &userID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return c.Status(fiber.StatusBadRequest).SendString("Verification link is invalid or expired.") - } - log.Printf("unable to validate verification token: %v", err) - return c.Status(fiber.StatusInternalServerError).SendString("Unable to verify email right now.") - } - - tx, err := db.Begin() - if err != nil { - return c.Status(fiber.StatusInternalServerError).SendString("Unable to verify email right now.") - } - if _, err := tx.Exec(`UPDATE users SET email_verified = 1, updated_at = datetime('now') WHERE id = ?`, userID); err != nil { - tx.Rollback() - log.Printf("unable to mark email verified: %v", err) - return c.Status(fiber.StatusInternalServerError).SendString("Unable to verify email right now.") - } - if _, err := tx.Exec(`UPDATE email_verification_tokens SET used_at = ? WHERE id = ?`, time.Now().Unix(), tokenID); err != nil { - tx.Rollback() - log.Printf("unable to mark verify token used: %v", err) - return c.Status(fiber.StatusInternalServerError).SendString("Unable to verify email right now.") - } - if err := tx.Commit(); err != nil { - log.Printf("unable to commit verify email tx: %v", err) - return c.Status(fiber.StatusInternalServerError).SendString("Unable to verify email right now.") - } - - return c.SendString("Email verified successfully. You can now log in.") - }) - - app.Get("/", func(c fiber.Ctx) error { - data := landingData{ - Brand: "Bag Exchange", - InitialTitle: "Bag Exchange | Swap bags and handbags", - FooterText: "© 2026 Bag Exchange · Bag and handbag exchange between individuals", - AssetVersion: assetVersion, - } - - var out bytes.Buffer - if err := landingTemplate.Execute(&out, data); err != nil { - return c.Status(fiber.StatusInternalServerError).SendString("unable to render page") - } - - c.Type("html", "utf-8") - return c.SendString(out.String()) - }) - app.Get("/reset-password", func(c fiber.Ctx) error { - token := strings.TrimSpace(c.Query("token")) - var out bytes.Buffer - if err := resetPasswordTemplate.Execute(&out, resetPasswordPageData{Token: token}); err != nil { - return c.Status(fiber.StatusInternalServerError).SendString("unable to render reset password page") - } - c.Type("html", "utf-8") - return c.SendString(out.String()) - }) - app.Get("/howtowork", func(c fiber.Ctx) error { - var out bytes.Buffer - if err := howToWorkTemplate.Execute(&out, nil); err != nil { - return c.Status(fiber.StatusInternalServerError).SendString("unable to render howtowork page") - } - c.Type("html", "utf-8") - return c.SendString(out.String()) - }) - app.Get("/login", func(c fiber.Ctx) error { - var out bytes.Buffer - if err := loginTemplate.Execute(&out, nil); err != nil { - return c.Status(fiber.StatusInternalServerError).SendString("unable to render login page") - } - c.Type("html", "utf-8") - return c.SendString(out.String()) - }) - app.Get("/signup", func(c fiber.Ctx) error { - var out bytes.Buffer - if err := signupTemplate.Execute(&out, nil); err != nil { - return c.Status(fiber.StatusInternalServerError).SendString("unable to render signup page") - } - c.Type("html", "utf-8") - return c.SendString(out.String()) - }) - app.Get("/forgot-password", func(c fiber.Ctx) error { - var out bytes.Buffer - if err := forgotPasswordTemplate.Execute(&out, nil); err != nil { - return c.Status(fiber.StatusInternalServerError).SendString("unable to render forgot password page") - } - c.Type("html", "utf-8") - return c.SendString(out.String()) - }) - - log.Fatal(app.Listen(":6081")) + log.Fatal(app.Listen(":6082")) } func buildAssetVersion(paths ...string) string { @@ -581,21 +104,20 @@ func buildAssetVersion(paths ...string) string { return hex.EncodeToString(hasher.Sum(nil))[:12] } -func hashEmail(email string) string { - sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(email)))) - return hex.EncodeToString(sum[:]) -} - -func initDB(path string) (*sql.DB, error) { +func initDB(path string) (*gorm.DB, error) { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return nil, err } - db, err := sql.Open("sqlite", path) + db, err := gorm.Open(sqlite.Open(path), &gorm.Config{}) if err != nil { return nil, err } + if err := db.Exec("PRAGMA foreign_keys = ON;").Error; err != nil { + return nil, err + } + schema := ` CREATE TABLE IF NOT EXISTS subscribers ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -606,8 +128,7 @@ func initDB(path string) (*sql.DB, error) { browser_data TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) );` - if _, err := db.Exec(schema); err != nil { - db.Close() + if err := db.Exec(schema).Error; err != nil { return nil, err } @@ -620,8 +141,7 @@ func initDB(path string) (*sql.DB, error) { created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) );` - if _, err := db.Exec(userSchema); err != nil { - db.Close() + if err := db.Exec(userSchema).Error; err != nil { return nil, err } @@ -634,8 +154,7 @@ func initDB(path string) (*sql.DB, error) { created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE );` - if _, err := db.Exec(sessionSchema); err != nil { - db.Close() + if err := db.Exec(sessionSchema).Error; err != nil { return nil, err } @@ -649,8 +168,7 @@ func initDB(path string) (*sql.DB, error) { created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE );` - if _, err := db.Exec(resetSchema); err != nil { - db.Close() + if err := db.Exec(resetSchema).Error; err != nil { return nil, err } @@ -664,8 +182,7 @@ func initDB(path string) (*sql.DB, error) { created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE );` - if _, err := db.Exec(verifySchema); err != nil { - db.Close() + if err := db.Exec(verifySchema).Error; err != nil { return nil, err } @@ -679,75 +196,6 @@ func isUniqueConstraint(err error) bool { return strings.Contains(strings.ToLower(err.Error()), "unique") } -func setSubscriptionCookie(c fiber.Ctx, value string) { - c.Cookie(&fiber.Cookie{ - Name: subscriptionCookieName, - Value: value, - Path: "/", - HTTPOnly: false, - Secure: false, - Expires: time.Now().AddDate(1, 0, 0), - MaxAge: 60 * 60 * 24 * 365, - }) -} - -func setSessionCookie(c fiber.Ctx, value string, expiresAtUnix int64) { - c.Cookie(&fiber.Cookie{ - Name: sessionCookieName, - Value: value, - Path: "/", - HTTPOnly: true, - Secure: false, - SameSite: "Lax", - Expires: time.Unix(expiresAtUnix, 0), - MaxAge: 60 * 60 * 24 * sessionDurationDays, - }) -} - -func clearSessionCookie(c fiber.Ctx) { - c.Cookie(&fiber.Cookie{ - Name: sessionCookieName, - Value: "", - Path: "/", - HTTPOnly: true, - Secure: false, - SameSite: "Lax", - Expires: time.Unix(0, 0), - MaxAge: -1, - }) -} - -func hashToken(value string) string { - sum := sha256.Sum256([]byte(value)) - return hex.EncodeToString(sum[:]) -} - -func generateSessionToken() (string, error) { - raw := make([]byte, 32) - if _, err := rand.Read(raw); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(raw), nil -} - -func isStrongPassword(value string) bool { - if len(value) < 8 { - return false - } - - hasLetter := false - hasDigit := false - for _, r := range value { - if unicode.IsLetter(r) { - hasLetter = true - } - if unicode.IsDigit(r) { - hasDigit = true - } - } - return hasLetter && hasDigit -} - func loadDotEnv(path string) { file, err := os.Open(path) if err != nil { @@ -775,135 +223,3 @@ func loadDotEnv(path string) { } } } - -func getSMTPConfigFromEnv() smtpConfig { - port := 587 - if rawPort := strings.TrimSpace(os.Getenv("STARTTLS_PORT")); rawPort != "" { - if p, err := strconv.Atoi(rawPort); err == nil { - port = p - } - } - return smtpConfig{ - Host: strings.TrimSpace(os.Getenv("SMTP")), - Port: port, - User: strings.TrimSpace(os.Getenv("SMTP_USER")), - Password: strings.TrimSpace(os.Getenv("SMTP_PASSWORD")), - } -} - -func (s smtpConfig) isConfigured() bool { - return s.Host != "" && s.User != "" && s.Password != "" && s.Port > 0 -} - -func getAppBaseURL() string { - base := strings.TrimSpace(os.Getenv("SITE_URL")) - if base == "" { - base = strings.TrimSpace(os.Getenv("APP_BASE_URL")) - } - if base == "" { - return "http://localhost:6081" - } - return strings.TrimRight(base, "/") -} - -func sendPasswordResetEmail(config smtpConfig, recipientEmail, resetURL string) error { - body, err := renderTransactionalTemplate("password_reset.html", map[string]string{ - "ActionURL": resetURL, - }) - if err != nil { - return err - } - return sendHTMLEmail(config, recipientEmail, "Bag Exchange - Password reset", body) -} - -func sendEmailVerificationEmail(config smtpConfig, recipientEmail, verifyURL string) error { - body, err := renderTransactionalTemplate("verify_email.html", map[string]string{ - "ActionURL": verifyURL, - }) - if err != nil { - return err - } - return sendHTMLEmail(config, recipientEmail, "Bag Exchange - Verify your email", body) -} - -func sendHTMLEmail(config smtpConfig, recipientEmail, subject, htmlBody string) error { - if isDevEmailMode() { - return saveDevEmail(recipientEmail, subject, htmlBody) - } - - auth := smtp.PlainAuth("", config.User, config.Password, config.Host) - message := "From: " + config.User + "\r\n" + - "To: " + recipientEmail + "\r\n" + - "Subject: " + subject + "\r\n" + - "MIME-Version: 1.0\r\n" + - "Content-Type: text/html; charset=UTF-8\r\n\r\n" + - htmlBody - return smtp.SendMail(fmt.Sprintf("%s:%d", config.Host, config.Port), auth, config.User, []string{recipientEmail}, []byte(message)) -} - -func renderTransactionalTemplate(templateName string, data any) (string, error) { - tmpl, err := template.ParseFiles(filepath.Join("templates", "transactionalMails", templateName)) - if err != nil { - return "", err - } - var out bytes.Buffer - if err := tmpl.Execute(&out, data); err != nil { - return "", err - } - return out.String(), nil -} - -func createEmailVerificationToken(db *sql.DB, userID int64) (string, error) { - token, err := generateSessionToken() - if err != nil { - return "", err - } - expiresAt := time.Now().Add(time.Hour * emailVerificationDurationHours).Unix() - _, err = db.Exec(` - INSERT INTO email_verification_tokens (user_id, token_hash, expires_at) - VALUES (?, ?, ?) - `, userID, hashToken(token), expiresAt) - if err != nil { - return "", err - } - return token, nil -} - -func isDevEmailMode() bool { - return strings.EqualFold(strings.TrimSpace(os.Getenv("EMAIL_MODE")), "dev") -} - -func saveDevEmail(recipientEmail, subject, htmlBody string) error { - const dir = "devEmails" - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - - filename := fmt.Sprintf("%s_%s.html", time.Now().UTC().Format("20060102_150405"), sanitizeFilename(subject)) - path := filepath.Join(dir, filename) - - payload := "\n" + htmlBody - - return os.WriteFile(path, []byte(payload), 0o644) -} - -func sanitizeFilename(value string) string { - var b strings.Builder - for _, r := range strings.ToLower(strings.TrimSpace(value)) { - switch { - case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'): - b.WriteRune(r) - case r == ' ' || r == '-' || r == '_': - b.WriteRune('_') - } - } - name := strings.Trim(b.String(), "_") - if name == "" { - return "email" - } - return name -} diff --git a/models.go b/models.go new file mode 100644 index 0000000..07ace76 --- /dev/null +++ b/models.go @@ -0,0 +1,71 @@ +package main + +// Subscriber maps the subscribers table. +type Subscriber struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement"` + Email string `gorm:"column:email;uniqueIndex;not null"` + IPAddress string `gorm:"column:ip_address;not null"` + UserAgent string `gorm:"column:user_agent;not null"` + AcceptLanguage string `gorm:"column:accept_language"` + BrowserData string `gorm:"column:browser_data"` + CreatedAt string `gorm:"column:created_at;not null"` +} + +func (Subscriber) TableName() string { + return "subscribers" +} + +// User maps the users table. +type User struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement"` + Email string `gorm:"column:email;uniqueIndex;not null"` + PasswordHash string `gorm:"column:password_hash;not null"` + EmailVerified int `gorm:"column:email_verified;not null;default:0"` + CreatedAt string `gorm:"column:created_at;not null"` + UpdatedAt string `gorm:"column:updated_at;not null"` +} + +func (User) TableName() string { + return "users" +} + +// Session maps the sessions table. +type Session struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement"` + UserID int64 `gorm:"column:user_id;not null;index"` + TokenHash string `gorm:"column:token_hash;uniqueIndex;not null"` + ExpiresAt int64 `gorm:"column:expires_at;not null"` + CreatedAt string `gorm:"column:created_at;not null"` +} + +func (Session) TableName() string { + return "sessions" +} + +// PasswordResetToken maps the password_reset_tokens table. +type PasswordResetToken struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement"` + UserID int64 `gorm:"column:user_id;not null;index"` + TokenHash string `gorm:"column:token_hash;uniqueIndex;not null"` + ExpiresAt int64 `gorm:"column:expires_at;not null"` + UsedAt *int64 `gorm:"column:used_at"` + CreatedAt string `gorm:"column:created_at;not null"` +} + +func (PasswordResetToken) TableName() string { + return "password_reset_tokens" +} + +// EmailVerificationToken maps the email_verification_tokens table. +type EmailVerificationToken struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement"` + UserID int64 `gorm:"column:user_id;not null;index"` + TokenHash string `gorm:"column:token_hash;uniqueIndex;not null"` + ExpiresAt int64 `gorm:"column:expires_at;not null"` + UsedAt *int64 `gorm:"column:used_at"` + CreatedAt string `gorm:"column:created_at;not null"` +} + +func (EmailVerificationToken) TableName() string { + return "email_verification_tokens" +} diff --git a/smtp.go b/smtp.go new file mode 100644 index 0000000..9b82fba --- /dev/null +++ b/smtp.go @@ -0,0 +1,136 @@ +package main + +import ( + "bytes" + "fmt" + "html/template" + "net/smtp" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +type smtpConfig struct { + Host string + Port int + User string + Password string +} + +func getSMTPConfigFromEnv() smtpConfig { + port := 587 + if rawPort := strings.TrimSpace(os.Getenv("STARTTLS_PORT")); rawPort != "" { + if p, err := strconv.Atoi(rawPort); err == nil { + port = p + } + } + return smtpConfig{ + Host: strings.TrimSpace(os.Getenv("SMTP")), + Port: port, + User: strings.TrimSpace(os.Getenv("SMTP_USER")), + Password: strings.TrimSpace(os.Getenv("SMTP_PASSWORD")), + } +} + +func (s smtpConfig) isConfigured() bool { + return s.Host != "" && s.User != "" && s.Password != "" && s.Port > 0 +} + +func getAppBaseURL() string { + base := strings.TrimSpace(os.Getenv("SITE_URL")) + if base == "" { + base = strings.TrimSpace(os.Getenv("APP_BASE_URL")) + } + if base == "" { + return "http://localhost:6081" + } + return strings.TrimRight(base, "/") +} + +func sendPasswordResetEmail(config smtpConfig, recipientEmail, resetURL string) error { + body, err := renderTransactionalTemplate("password_reset.html", map[string]string{ + "ActionURL": resetURL, + }) + if err != nil { + return err + } + return sendHTMLEmail(config, recipientEmail, "Bag Exchange - Password reset", body) +} + +func sendEmailVerificationEmail(config smtpConfig, recipientEmail, verifyURL string) error { + body, err := renderTransactionalTemplate("verify_email.html", map[string]string{ + "ActionURL": verifyURL, + }) + if err != nil { + return err + } + return sendHTMLEmail(config, recipientEmail, "Bag Exchange - Verify your email", body) +} + +func sendHTMLEmail(config smtpConfig, recipientEmail, subject, htmlBody string) error { + if isDevEmailMode() { + return saveDevEmail(recipientEmail, subject, htmlBody) + } + + auth := smtp.PlainAuth("", config.User, config.Password, config.Host) + message := "From: " + config.User + "\r\n" + + "To: " + recipientEmail + "\r\n" + + "Subject: " + subject + "\r\n" + + "MIME-Version: 1.0\r\n" + + "Content-Type: text/html; charset=UTF-8\r\n\r\n" + + htmlBody + return smtp.SendMail(fmt.Sprintf("%s:%d", config.Host, config.Port), auth, config.User, []string{recipientEmail}, []byte(message)) +} + +func renderTransactionalTemplate(templateName string, data any) (string, error) { + tmpl, err := template.ParseFiles(filepath.Join("templates", "transactionalMails", templateName)) + if err != nil { + return "", err + } + var out bytes.Buffer + if err := tmpl.Execute(&out, data); err != nil { + return "", err + } + return out.String(), nil +} + +func isDevEmailMode() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv("EMAIL_MODE")), "dev") +} + +func saveDevEmail(recipientEmail, subject, htmlBody string) error { + const dir = "devEmails" + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + + filename := fmt.Sprintf("%s_%s.html", time.Now().UTC().Format("20060102_150405"), sanitizeFilename(subject)) + path := filepath.Join(dir, filename) + + payload := "\n" + htmlBody + + return os.WriteFile(path, []byte(payload), 0o644) +} + +func sanitizeFilename(value string) string { + var b strings.Builder + for _, r := range strings.ToLower(strings.TrimSpace(value)) { + switch { + case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'): + b.WriteRune(r) + case r == ' ' || r == '-' || r == '_': + b.WriteRune('_') + } + } + name := strings.Trim(b.String(), "_") + if name == "" { + return "email" + } + return name +} diff --git a/templates/private/welcome.html b/templates/private/welcome.html new file mode 100644 index 0000000..d88f12e --- /dev/null +++ b/templates/private/welcome.html @@ -0,0 +1,19 @@ + + +
+ + ++ Back to homepage +
+