fatto ordine
This commit is contained in:
160
controller.go
Normal file
160
controller.go
Normal 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,
|
||||
})
|
||||
}
|
||||
520
controllers/auth_controller.go
Normal file
520
controllers/auth_controller.go
Normal 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
|
||||
}
|
||||
88
controllers/subscribe_controller.go
Normal file
88
controllers/subscribe_controller.go
Normal 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,
|
||||
})
|
||||
}
|
||||
7
controllers/user_conntroller.go
Normal file
7
controllers/user_conntroller.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package controllers
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
type UserConntroller struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
Binary file not shown.
18
go.mod
18
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
|
||||
)
|
||||
|
||||
24
go.sum
24
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=
|
||||
|
||||
814
main.go
814
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" +
|
||||
"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
71
models.go
Normal 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
136
smtp.go
Normal 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
|
||||
}
|
||||
19
templates/private/welcome.html
Normal file
19
templates/private/welcome.html
Normal 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>
|
||||
@@ -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 {
|
||||
Reference in New Issue
Block a user