code created by GPT-5.3-Codex
This commit is contained in:
909
main.go
Normal file
909
main.go
Normal file
@@ -0,0 +1,909 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user