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" ) type landingData struct { Brand string InitialTitle string FooterText string AssetVersion string } 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") db, err := initDB("data/subscribers.db") if err != nil { log.Fatal(err) } defer db.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") 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"}) } 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")) } func buildAssetVersion(paths ...string) string { hasher := sha256.New() for _, path := range paths { content, err := os.ReadFile(path) if err != nil { log.Printf("asset version fallback, unable to read %s: %v", path, err) return "dev" } hasher.Write(content) } 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) { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return nil, err } db, err := sql.Open("sqlite", path) 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() 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 } 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 } return db, nil } func isUniqueConstraint(err error) bool { if err == nil { return false } 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 { return } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } parts := strings.SplitN(line, "=", 2) if len(parts) != 2 { continue } key := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) if key == "" { continue } if _, exists := os.LookupEnv(key); !exists { os.Setenv(key, value) } } } func getSMTPConfigFromEnv() smtpConfig { port := 587 if rawPort := strings.TrimSpace(os.Getenv("STARTTLS_PORT")); rawPort != "" { if p, err := strconv.Atoi(rawPort); err == nil { port = p } } return smtpConfig{ Host: strings.TrimSpace(os.Getenv("SMTP")), Port: port, User: strings.TrimSpace(os.Getenv("SMTP_USER")), Password: strings.TrimSpace(os.Getenv("SMTP_PASSWORD")), } } func (s smtpConfig) isConfigured() bool { return s.Host != "" && s.User != "" && s.Password != "" && s.Port > 0 } func getAppBaseURL() string { base := strings.TrimSpace(os.Getenv("SITE_URL")) if base == "" { base = strings.TrimSpace(os.Getenv("APP_BASE_URL")) } if base == "" { return "http://localhost:6081" } return strings.TrimRight(base, "/") } func sendPasswordResetEmail(config smtpConfig, recipientEmail, resetURL string) error { body, err := renderTransactionalTemplate("password_reset.html", map[string]string{ "ActionURL": resetURL, }) if err != nil { return err } return sendHTMLEmail(config, recipientEmail, "Bag Exchange - Password reset", body) } func sendEmailVerificationEmail(config smtpConfig, recipientEmail, verifyURL string) error { body, err := renderTransactionalTemplate("verify_email.html", map[string]string{ "ActionURL": verifyURL, }) if err != nil { return err } return sendHTMLEmail(config, recipientEmail, "Bag Exchange - Verify your email", body) } func sendHTMLEmail(config smtpConfig, recipientEmail, subject, htmlBody string) error { if isDevEmailMode() { return saveDevEmail(recipientEmail, subject, htmlBody) } auth := smtp.PlainAuth("", config.User, config.Password, config.Host) message := "From: " + config.User + "\r\n" + "To: " + recipientEmail + "\r\n" + "Subject: " + subject + "\r\n" + "MIME-Version: 1.0\r\n" + "Content-Type: text/html; charset=UTF-8\r\n\r\n" + htmlBody return smtp.SendMail(fmt.Sprintf("%s:%d", config.Host, config.Port), auth, config.User, []string{recipientEmail}, []byte(message)) } func renderTransactionalTemplate(templateName string, data any) (string, error) { tmpl, err := template.ParseFiles(filepath.Join("templates", "transactionalMails", templateName)) if err != nil { return "", err } var out bytes.Buffer if err := tmpl.Execute(&out, data); err != nil { return "", err } return out.String(), nil } func createEmailVerificationToken(db *sql.DB, userID int64) (string, error) { token, err := generateSessionToken() if err != nil { return "", err } expiresAt := time.Now().Add(time.Hour * emailVerificationDurationHours).Unix() _, err = db.Exec(` INSERT INTO email_verification_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?) `, userID, hashToken(token), expiresAt) if err != nil { return "", err } return token, nil } func isDevEmailMode() bool { return strings.EqualFold(strings.TrimSpace(os.Getenv("EMAIL_MODE")), "dev") } func saveDevEmail(recipientEmail, subject, htmlBody string) error { const dir = "devEmails" if err := os.MkdirAll(dir, 0o755); err != nil { return err } filename := fmt.Sprintf("%s_%s.html", time.Now().UTC().Format("20060102_150405"), sanitizeFilename(subject)) path := filepath.Join(dir, filename) payload := "\n" + htmlBody return os.WriteFile(path, []byte(payload), 0o644) } func sanitizeFilename(value string) string { var b strings.Builder for _, r := range strings.ToLower(strings.TrimSpace(value)) { switch { case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'): b.WriteRune(r) case r == ' ' || r == '-' || r == '_': b.WriteRune('_') } } name := strings.Trim(b.String(), "_") if name == "" { return "email" } return name }