Files
BagExchange/main.go
2026-02-15 17:09:19 +01:00

910 lines
29 KiB
Go

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