Compare commits

...

2 Commits

Author SHA1 Message Date
fabio
3bf53e93eb auto migrazione gorm 2026-02-19 20:14:14 +01:00
fabio
5db09510d2 fatto ordine 2026-02-19 20:08:06 +01:00
17 changed files with 1091 additions and 830 deletions

160
controller.go Normal file
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
package controllers
import "gorm.io/gorm"
type UserConntroller struct {
DB *gorm.DB
}

Binary file not shown.

18
go.mod
View File

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

24
go.sum
View File

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

876
main.go
View File

@@ -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,92 +104,34 @@ 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
}
schema := `
CREATE TABLE IF NOT EXISTS subscribers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
ip_address TEXT NOT NULL,
user_agent TEXT NOT NULL,
accept_language TEXT,
browser_data TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);`
if _, err := db.Exec(schema); err != nil {
db.Close()
if err := db.Exec("PRAGMA foreign_keys = ON;").Error; err != nil {
return nil, err
}
userSchema := `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
email_verified INTEGER NOT NULL DEFAULT 0,
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()
return nil, err
models := []any{
&Subscriber{},
&User{},
&Session{},
&PasswordResetToken{},
&EmailVerificationToken{},
}
sessionSchema := `
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at INTEGER NOT NULL,
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()
return nil, err
}
resetSchema := `
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at INTEGER NOT NULL,
used_at INTEGER,
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()
return nil, err
}
verifySchema := `
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at INTEGER NOT NULL,
used_at INTEGER,
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()
return nil, err
for _, model := range models {
if db.Migrator().HasTable(model) {
continue
}
if err := db.AutoMigrate(model); err != nil {
return nil, err
}
}
return db, nil
@@ -679,75 +144,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 +171,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" +
"To: " + recipientEmail + "\n" +
"Subject: " + subject + "\n" +
"GeneratedAtUTC: " + time.Now().UTC().Format(time.RFC3339) + "\n" +
"-->\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
}

71
models.go Normal file
View File

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

136
smtp.go Normal file
View File

@@ -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" +
"To: " + recipientEmail + "\n" +
"Subject: " + subject + "\n" +
"GeneratedAtUTC: " + time.Now().UTC().Format(time.RFC3339) + "\n" +
"-->\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
}

View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Bag Exchange | Welcome</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-gradient-to-br from-amber-50 via-orange-50 to-stone-200 text-zinc-800">
<main class="mx-auto grid min-h-screen w-full max-w-2xl place-items-center px-4 py-8">
<section class="w-full max-w-xl rounded-2xl border border-zinc-300/70 bg-amber-50/80 p-6 shadow-2xl shadow-stone-700/10 backdrop-blur-sm text-center">
<h1 class="text-3xl font-bold tracking-tight">Welcome {{.Email}}</h1>
<p class="mt-4 text-sm">
<a class="text-emerald-900 underline decoration-1 underline-offset-2" href="/">Back to homepage</a>
</p>
</section>
</main>
</body>
</html>

View File

@@ -86,7 +86,7 @@
try { payload = await response.json(); } catch (e) {}
if (response.ok) {
window.location.href = "/?lang=" + encodeURIComponent(i18n.getLanguage());
window.location.href = "/welcome?lang=" + encodeURIComponent(i18n.getLanguage());
} else if (payload.error === "email_not_verified") {
showMessage("error", i18n.t("msgNotVerified"));
} else {