910 lines
29 KiB
Go
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
|
|
}
|