fatto ordine
This commit is contained in:
160
controller.go
Normal file
160
controller.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"html/template"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"prada.ch/controllers"
|
||||||
|
)
|
||||||
|
|
||||||
|
type controller struct {
|
||||||
|
db *gorm.DB
|
||||||
|
mailConfig smtpConfig
|
||||||
|
authController *controllers.AuthController
|
||||||
|
subscribeController *controllers.SubscribeController
|
||||||
|
assetVersion string
|
||||||
|
landingTemplate *template.Template
|
||||||
|
howToWorkTemplate *template.Template
|
||||||
|
loginTemplate *template.Template
|
||||||
|
signupTemplate *template.Template
|
||||||
|
forgotPasswordTemplate *template.Template
|
||||||
|
resetPasswordTemplate *template.Template
|
||||||
|
welcomeTemplate *template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionCookieName = "bag_exchange_session"
|
||||||
|
|
||||||
|
func (h *controller) staticFile(c fiber.Ctx) error {
|
||||||
|
relativePath := filepath.Clean(c.Params("*"))
|
||||||
|
if relativePath == "." || strings.HasPrefix(relativePath, "..") {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
|
return c.SendFile(filepath.Join("static", relativePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *controller) home(c fiber.Ctx) error {
|
||||||
|
data := landingData{
|
||||||
|
Brand: "Bag Exchange",
|
||||||
|
InitialTitle: "Bag Exchange | Swap bags and handbags",
|
||||||
|
FooterText: "© 2026 Bag Exchange · Bag and handbag exchange between individuals",
|
||||||
|
AssetVersion: h.assetVersion,
|
||||||
|
}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := h.landingTemplate.Execute(&out, data); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).SendString("unable to render page")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Type("html", "utf-8")
|
||||||
|
return c.SendString(out.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *controller) resetPasswordPage(c fiber.Ctx) error {
|
||||||
|
token := strings.TrimSpace(c.Query("token"))
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := h.resetPasswordTemplate.Execute(&out, resetPasswordPageData{Token: token}); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).SendString("unable to render reset password page")
|
||||||
|
}
|
||||||
|
c.Type("html", "utf-8")
|
||||||
|
return c.SendString(out.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *controller) howToWorkPage(c fiber.Ctx) error {
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := h.howToWorkTemplate.Execute(&out, nil); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).SendString("unable to render howtowork page")
|
||||||
|
}
|
||||||
|
c.Type("html", "utf-8")
|
||||||
|
return c.SendString(out.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *controller) loginPage(c fiber.Ctx) error {
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := h.loginTemplate.Execute(&out, nil); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).SendString("unable to render login page")
|
||||||
|
}
|
||||||
|
c.Type("html", "utf-8")
|
||||||
|
return c.SendString(out.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *controller) signupPage(c fiber.Ctx) error {
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := h.signupTemplate.Execute(&out, nil); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).SendString("unable to render signup page")
|
||||||
|
}
|
||||||
|
c.Type("html", "utf-8")
|
||||||
|
return c.SendString(out.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *controller) forgotPasswordPage(c fiber.Ctx) error {
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := h.forgotPasswordTemplate.Execute(&out, nil); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).SendString("unable to render forgot password page")
|
||||||
|
}
|
||||||
|
c.Type("html", "utf-8")
|
||||||
|
return c.SendString(out.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *controller) welcomePage(c fiber.Ctx) error {
|
||||||
|
email, ok := h.currentUserEmail(c)
|
||||||
|
if !ok {
|
||||||
|
return c.Redirect().To("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := h.welcomeTemplate.Execute(&out, map[string]string{"Email": email}); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).SendString("unable to render welcome page")
|
||||||
|
}
|
||||||
|
c.Type("html", "utf-8")
|
||||||
|
return c.SendString(out.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *controller) currentUserEmail(c fiber.Ctx) (string, bool) {
|
||||||
|
sessionToken := c.Cookies(sessionCookieName)
|
||||||
|
if sessionToken == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionUser struct {
|
||||||
|
Email string `gorm:"column:email"`
|
||||||
|
}
|
||||||
|
err := h.db.Table("sessions").
|
||||||
|
Select("users.email").
|
||||||
|
Joins("JOIN users ON users.id = sessions.user_id").
|
||||||
|
Where("sessions.token_hash = ? AND sessions.expires_at > ?", hashSessionToken(sessionToken), time.Now().Unix()).
|
||||||
|
Take(&sessionUser).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
clearAuthSessionCookie(c)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return sessionUser.Email, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashSessionToken(value string) string {
|
||||||
|
sum := sha256.Sum256([]byte(value))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAuthSessionCookie(c fiber.Ctx) {
|
||||||
|
c.Cookie(&fiber.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HTTPOnly: true,
|
||||||
|
Secure: false,
|
||||||
|
SameSite: "Lax",
|
||||||
|
Expires: time.Unix(0, 0),
|
||||||
|
MaxAge: -1,
|
||||||
|
})
|
||||||
|
}
|
||||||
520
controllers/auth_controller.go
Normal file
520
controllers/auth_controller.go
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthController struct {
|
||||||
|
DB *gorm.DB
|
||||||
|
IsUniqueConstraint func(error) bool
|
||||||
|
AppBaseURL func() string
|
||||||
|
MailConfigured func() bool
|
||||||
|
SendPasswordResetEmail func(recipientEmail, resetURL string) error
|
||||||
|
SendEmailVerificationMail func(recipientEmail, verifyURL string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type registerRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
ConfirmPassword string `json:"confirmPassword"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type forgotPasswordRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type resetPasswordRequest struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
ConfirmPassword string `json:"confirmPassword"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type resendVerificationRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionCookieName = "bag_exchange_session"
|
||||||
|
const sessionDurationDays = 7
|
||||||
|
const passwordResetDurationMinutes = 60
|
||||||
|
const emailVerificationDurationHours = 24
|
||||||
|
|
||||||
|
func (a *AuthController) Register(c fiber.Ctx) error {
|
||||||
|
var req registerRequest
|
||||||
|
if err := json.Unmarshal(c.Body(), &req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_payload"})
|
||||||
|
}
|
||||||
|
|
||||||
|
email := strings.ToLower(strings.TrimSpace(req.Email))
|
||||||
|
if _, err := mail.ParseAddress(email); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_email"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Password != req.ConfirmPassword {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "password_mismatch"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isStrongPassword(req.Password) {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "password_too_weak"})
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("unable to hash password: %v", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_register"})
|
||||||
|
}
|
||||||
|
|
||||||
|
err = a.DB.Table("users").Create(map[string]any{
|
||||||
|
"email": email,
|
||||||
|
"password_hash": string(passwordHash),
|
||||||
|
"email_verified": 0,
|
||||||
|
}).Error
|
||||||
|
if err != nil {
|
||||||
|
if a.IsUniqueConstraint != nil && a.IsUniqueConstraint(err) {
|
||||||
|
return c.Status(fiber.StatusConflict).JSON(map[string]string{"error": "email_exists"})
|
||||||
|
}
|
||||||
|
log.Printf("unable to register user: %v", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_register"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var createdUser struct {
|
||||||
|
ID int64 `gorm:"column:id"`
|
||||||
|
}
|
||||||
|
err = a.DB.Table("users").Select("id").Where("email = ?", email).Take(&createdUser).Error
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("unable to read new user id: %v", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_register"})
|
||||||
|
}
|
||||||
|
userID := createdUser.ID
|
||||||
|
|
||||||
|
verifyToken, err := createEmailVerificationToken(a.DB, userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("unable to create verify token: %v", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_register"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.MailConfigured != nil && a.MailConfigured() && a.AppBaseURL != nil && a.SendEmailVerificationMail != nil {
|
||||||
|
verifyURL := fmt.Sprintf("%s/auth/verify-email?token=%s", a.AppBaseURL(), verifyToken)
|
||||||
|
if err := a.SendEmailVerificationMail(email, verifyURL); err != nil {
|
||||||
|
log.Printf("unable to send verification email: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(fiber.StatusCreated).JSON(map[string]string{"status": "registered_pending_verification"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthController) Login(c fiber.Ctx) error {
|
||||||
|
var req loginRequest
|
||||||
|
if err := json.Unmarshal(c.Body(), &req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_payload"})
|
||||||
|
}
|
||||||
|
|
||||||
|
email := strings.ToLower(strings.TrimSpace(req.Email))
|
||||||
|
if _, err := mail.ParseAddress(email); err != nil || req.Password == "" {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(map[string]string{"error": "invalid_credentials"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var loginUser struct {
|
||||||
|
ID int64 `gorm:"column:id"`
|
||||||
|
PasswordHash string `gorm:"column:password_hash"`
|
||||||
|
EmailVerified int `gorm:"column:email_verified"`
|
||||||
|
}
|
||||||
|
err := a.DB.Table("users").
|
||||||
|
Select("id, password_hash, email_verified").
|
||||||
|
Where("email = ?", email).
|
||||||
|
Take(&loginUser).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(map[string]string{"error": "invalid_credentials"})
|
||||||
|
}
|
||||||
|
log.Printf("unable to fetch user for login: %v", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_login"})
|
||||||
|
}
|
||||||
|
userID := loginUser.ID
|
||||||
|
passwordHash := loginUser.PasswordHash
|
||||||
|
emailVerified := loginUser.EmailVerified
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(map[string]string{"error": "invalid_credentials"})
|
||||||
|
}
|
||||||
|
if emailVerified == 0 {
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(map[string]string{"error": "email_not_verified"})
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionToken, err := generateSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("unable to generate session token: %v", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_login"})
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt := time.Now().AddDate(0, 0, sessionDurationDays).Unix()
|
||||||
|
err = a.DB.Table("sessions").Create(map[string]any{
|
||||||
|
"user_id": userID,
|
||||||
|
"token_hash": hashToken(sessionToken),
|
||||||
|
"expires_at": expiresAt,
|
||||||
|
}).Error
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("unable to create session: %v", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_login"})
|
||||||
|
}
|
||||||
|
|
||||||
|
setSessionCookie(c, sessionToken, expiresAt)
|
||||||
|
return c.Status(fiber.StatusOK).JSON(map[string]any{
|
||||||
|
"status": "authenticated",
|
||||||
|
"email": email,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthController) Logout(c fiber.Ctx) error {
|
||||||
|
sessionToken := c.Cookies(sessionCookieName)
|
||||||
|
if sessionToken != "" {
|
||||||
|
if err := a.DB.Table("sessions").Where("token_hash = ?", hashToken(sessionToken)).Delete(nil).Error; err != nil {
|
||||||
|
log.Printf("unable to delete session: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearSessionCookie(c)
|
||||||
|
return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "logged_out"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthController) Me(c fiber.Ctx) error {
|
||||||
|
sessionToken := c.Cookies(sessionCookieName)
|
||||||
|
if sessionToken == "" {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(map[string]any{"authenticated": false})
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionUser struct {
|
||||||
|
UserID int64 `gorm:"column:user_id"`
|
||||||
|
Email string `gorm:"column:email"`
|
||||||
|
}
|
||||||
|
err := a.DB.Table("sessions").
|
||||||
|
Select("users.id AS user_id, users.email").
|
||||||
|
Joins("JOIN users ON users.id = sessions.user_id").
|
||||||
|
Where("sessions.token_hash = ? AND sessions.expires_at > ?", hashToken(sessionToken), time.Now().Unix()).
|
||||||
|
Take(&sessionUser).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
clearSessionCookie(c)
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(map[string]any{"authenticated": false})
|
||||||
|
}
|
||||||
|
log.Printf("unable to resolve session: %v", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_fetch_session"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(fiber.StatusOK).JSON(map[string]any{
|
||||||
|
"authenticated": true,
|
||||||
|
"userId": sessionUser.UserID,
|
||||||
|
"email": sessionUser.Email,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthController) ForgotPassword(c fiber.Ctx) error {
|
||||||
|
var req forgotPasswordRequest
|
||||||
|
if err := json.Unmarshal(c.Body(), &req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_payload"})
|
||||||
|
}
|
||||||
|
|
||||||
|
email := strings.ToLower(strings.TrimSpace(req.Email))
|
||||||
|
if _, err := mail.ParseAddress(email); err != nil {
|
||||||
|
return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var userID int64
|
||||||
|
err := a.DB.Table("users").Select("id").Where("email = ?", email).Take(&userID).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
log.Printf("unable to fetch user for forgot-password: %v", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_process"})
|
||||||
|
}
|
||||||
|
|
||||||
|
resetToken, err := generateSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("unable to generate reset token: %v", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_process"})
|
||||||
|
}
|
||||||
|
expiresAt := time.Now().Add(time.Minute * passwordResetDurationMinutes).Unix()
|
||||||
|
|
||||||
|
err = a.DB.Table("password_reset_tokens").Create(map[string]any{
|
||||||
|
"user_id": userID,
|
||||||
|
"token_hash": hashToken(resetToken),
|
||||||
|
"expires_at": expiresAt,
|
||||||
|
}).Error
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("unable to persist reset token: %v", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_process"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.MailConfigured != nil && a.MailConfigured() && a.AppBaseURL != nil && a.SendPasswordResetEmail != nil {
|
||||||
|
resetURL := fmt.Sprintf("%s/reset-password?token=%s", a.AppBaseURL(), resetToken)
|
||||||
|
if err := a.SendPasswordResetEmail(email, resetURL); err != nil {
|
||||||
|
log.Printf("unable to send reset email: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("smtp not configured: skip password reset email")
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthController) ResendVerification(c fiber.Ctx) error {
|
||||||
|
var req resendVerificationRequest
|
||||||
|
if err := json.Unmarshal(c.Body(), &req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_payload"})
|
||||||
|
}
|
||||||
|
|
||||||
|
email := strings.ToLower(strings.TrimSpace(req.Email))
|
||||||
|
if _, err := mail.ParseAddress(email); err != nil {
|
||||||
|
return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var resendUser struct {
|
||||||
|
ID int64 `gorm:"column:id"`
|
||||||
|
EmailVerified int `gorm:"column:email_verified"`
|
||||||
|
}
|
||||||
|
err := a.DB.Table("users").Select("id, email_verified").Where("email = ?", email).Take(&resendUser).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
log.Printf("unable to fetch user for resend verification: %v", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_process"})
|
||||||
|
}
|
||||||
|
userID := resendUser.ID
|
||||||
|
verified := resendUser.EmailVerified
|
||||||
|
if verified != 0 {
|
||||||
|
return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyToken, err := createEmailVerificationToken(a.DB, userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("unable to create verify token on resend: %v", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_process"})
|
||||||
|
}
|
||||||
|
if a.MailConfigured != nil && a.MailConfigured() && a.AppBaseURL != nil && a.SendEmailVerificationMail != nil {
|
||||||
|
verifyURL := fmt.Sprintf("%s/auth/verify-email?token=%s", a.AppBaseURL(), verifyToken)
|
||||||
|
if err := a.SendEmailVerificationMail(email, verifyURL); err != nil {
|
||||||
|
log.Printf("unable to resend verification email: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthController) ResetPassword(c fiber.Ctx) error {
|
||||||
|
var req resetPasswordRequest
|
||||||
|
if err := json.Unmarshal(c.Body(), &req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_payload"})
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimSpace(req.Token)
|
||||||
|
if token == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_token"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Password != req.ConfirmPassword {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "password_mismatch"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isStrongPassword(req.Password) {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "password_too_weak"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var resetTokenRow struct {
|
||||||
|
ID int64 `gorm:"column:id"`
|
||||||
|
UserID int64 `gorm:"column:user_id"`
|
||||||
|
}
|
||||||
|
err := a.DB.Table("password_reset_tokens").
|
||||||
|
Select("id, user_id").
|
||||||
|
Where("token_hash = ? AND used_at IS NULL AND expires_at > ?", hashToken(token), time.Now().Unix()).
|
||||||
|
Order("id DESC").
|
||||||
|
Take(&resetTokenRow).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_or_expired_token"})
|
||||||
|
}
|
||||||
|
log.Printf("unable to fetch reset token: %v", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"})
|
||||||
|
}
|
||||||
|
resetID := resetTokenRow.ID
|
||||||
|
userID := resetTokenRow.UserID
|
||||||
|
|
||||||
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("unable to hash password in reset: %v", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"})
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := a.DB.Begin()
|
||||||
|
if tx.Error != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Table("users").Where("id = ?", userID).Updates(map[string]any{
|
||||||
|
"password_hash": string(passwordHash),
|
||||||
|
"updated_at": gorm.Expr("datetime('now')"),
|
||||||
|
}).Error; err != nil {
|
||||||
|
_ = tx.Rollback().Error
|
||||||
|
log.Printf("unable to update password: %v", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"})
|
||||||
|
}
|
||||||
|
if err := tx.Table("password_reset_tokens").Where("id = ?", resetID).Update("used_at", time.Now().Unix()).Error; err != nil {
|
||||||
|
_ = tx.Rollback().Error
|
||||||
|
log.Printf("unable to mark reset token used: %v", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"})
|
||||||
|
}
|
||||||
|
if err := tx.Table("sessions").Where("user_id = ?", userID).Delete(nil).Error; err != nil {
|
||||||
|
_ = tx.Rollback().Error
|
||||||
|
log.Printf("unable to delete sessions after reset: %v", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"})
|
||||||
|
}
|
||||||
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
log.Printf("unable to commit reset tx: %v", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"})
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSessionCookie(c)
|
||||||
|
return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "password_reset"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthController) VerifyEmail(c fiber.Ctx) error {
|
||||||
|
token := strings.TrimSpace(c.Query("token"))
|
||||||
|
if token == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).SendString("Invalid verification token.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var verifyTokenRow struct {
|
||||||
|
ID int64 `gorm:"column:id"`
|
||||||
|
UserID int64 `gorm:"column:user_id"`
|
||||||
|
}
|
||||||
|
err := a.DB.Table("email_verification_tokens").
|
||||||
|
Select("id, user_id").
|
||||||
|
Where("token_hash = ? AND used_at IS NULL AND expires_at > ?", hashToken(token), time.Now().Unix()).
|
||||||
|
Order("id DESC").
|
||||||
|
Take(&verifyTokenRow).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return c.Status(fiber.StatusBadRequest).SendString("Verification link is invalid or expired.")
|
||||||
|
}
|
||||||
|
log.Printf("unable to validate verification token: %v", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).SendString("Unable to verify email right now.")
|
||||||
|
}
|
||||||
|
tokenID := verifyTokenRow.ID
|
||||||
|
userID := verifyTokenRow.UserID
|
||||||
|
|
||||||
|
tx := a.DB.Begin()
|
||||||
|
if tx.Error != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).SendString("Unable to verify email right now.")
|
||||||
|
}
|
||||||
|
if err := tx.Table("users").Where("id = ?", userID).Updates(map[string]any{
|
||||||
|
"email_verified": 1,
|
||||||
|
"updated_at": gorm.Expr("datetime('now')"),
|
||||||
|
}).Error; err != nil {
|
||||||
|
_ = tx.Rollback().Error
|
||||||
|
log.Printf("unable to mark email verified: %v", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).SendString("Unable to verify email right now.")
|
||||||
|
}
|
||||||
|
if err := tx.Table("email_verification_tokens").Where("id = ?", tokenID).Update("used_at", time.Now().Unix()).Error; err != nil {
|
||||||
|
_ = tx.Rollback().Error
|
||||||
|
log.Printf("unable to mark verify token used: %v", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).SendString("Unable to verify email right now.")
|
||||||
|
}
|
||||||
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
log.Printf("unable to commit verify email tx: %v", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).SendString("Unable to verify email right now.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.SendString("Email verified successfully. You can now log in.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSessionCookie(c fiber.Ctx, value string, expiresAtUnix int64) {
|
||||||
|
c.Cookie(&fiber.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: value,
|
||||||
|
Path: "/",
|
||||||
|
HTTPOnly: true,
|
||||||
|
Secure: false,
|
||||||
|
SameSite: "Lax",
|
||||||
|
Expires: time.Unix(expiresAtUnix, 0),
|
||||||
|
MaxAge: 60 * 60 * 24 * sessionDurationDays,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearSessionCookie(c fiber.Ctx) {
|
||||||
|
c.Cookie(&fiber.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HTTPOnly: true,
|
||||||
|
Secure: false,
|
||||||
|
SameSite: "Lax",
|
||||||
|
Expires: time.Unix(0, 0),
|
||||||
|
MaxAge: -1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashToken(value string) string {
|
||||||
|
sum := sha256.Sum256([]byte(value))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateSessionToken() (string, error) {
|
||||||
|
raw := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isStrongPassword(value string) bool {
|
||||||
|
if len(value) < 8 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
hasLetter := false
|
||||||
|
hasDigit := false
|
||||||
|
for _, r := range value {
|
||||||
|
if unicode.IsLetter(r) {
|
||||||
|
hasLetter = true
|
||||||
|
}
|
||||||
|
if unicode.IsDigit(r) {
|
||||||
|
hasDigit = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasLetter && hasDigit
|
||||||
|
}
|
||||||
|
|
||||||
|
func createEmailVerificationToken(db *gorm.DB, userID int64) (string, error) {
|
||||||
|
token, err := generateSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
expiresAt := time.Now().Add(time.Hour * emailVerificationDurationHours).Unix()
|
||||||
|
err = db.Table("email_verification_tokens").Create(map[string]any{
|
||||||
|
"user_id": userID,
|
||||||
|
"token_hash": hashToken(token),
|
||||||
|
"expires_at": expiresAt,
|
||||||
|
}).Error
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
88
controllers/subscribe_controller.go
Normal file
88
controllers/subscribe_controller.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SubscribeController struct {
|
||||||
|
DB *gorm.DB
|
||||||
|
IsUniqueConstraint func(error) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type subscribeRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
BrowserData map[string]any `json:"browserData"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionCookieName = "bag_exchange_subscribed"
|
||||||
|
|
||||||
|
func (s *SubscribeController) Subscribe(c fiber.Ctx) error {
|
||||||
|
var req subscribeRequest
|
||||||
|
if err := json.Unmarshal(c.Body(), &req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid payload"})
|
||||||
|
}
|
||||||
|
|
||||||
|
email := strings.ToLower(strings.TrimSpace(req.Email))
|
||||||
|
if _, err := mail.ParseAddress(email); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid email"})
|
||||||
|
}
|
||||||
|
|
||||||
|
emailHash := hashEmail(email)
|
||||||
|
if c.Cookies(subscriptionCookieName) == emailHash {
|
||||||
|
return c.Status(fiber.StatusConflict).JSON(map[string]string{"status": "already_submitted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
browserData := "{}"
|
||||||
|
if len(req.BrowserData) > 0 {
|
||||||
|
payload, err := json.Marshal(req.BrowserData)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid browser data"})
|
||||||
|
}
|
||||||
|
browserData = string(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.DB.Table("subscribers").Create(map[string]any{
|
||||||
|
"email": email,
|
||||||
|
"ip_address": c.IP(),
|
||||||
|
"user_agent": c.Get("User-Agent"),
|
||||||
|
"accept_language": c.Get("Accept-Language"),
|
||||||
|
"browser_data": browserData,
|
||||||
|
}).Error
|
||||||
|
if err != nil {
|
||||||
|
if s.IsUniqueConstraint != nil && s.IsUniqueConstraint(err) {
|
||||||
|
setSubscriptionCookie(c, emailHash)
|
||||||
|
return c.Status(fiber.StatusConflict).JSON(map[string]string{"status": "already_submitted"})
|
||||||
|
}
|
||||||
|
log.Printf("unable to insert subscriber: %v", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable to save subscription"})
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubscriptionCookie(c, emailHash)
|
||||||
|
return c.Status(fiber.StatusCreated).JSON(map[string]string{"status": "subscribed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashEmail(email string) string {
|
||||||
|
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(email))))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSubscriptionCookie(c fiber.Ctx, value string) {
|
||||||
|
c.Cookie(&fiber.Cookie{
|
||||||
|
Name: subscriptionCookieName,
|
||||||
|
Value: value,
|
||||||
|
Path: "/",
|
||||||
|
HTTPOnly: false,
|
||||||
|
Secure: false,
|
||||||
|
Expires: time.Now().AddDate(1, 0, 0),
|
||||||
|
MaxAge: 60 * 60 * 24 * 365,
|
||||||
|
})
|
||||||
|
}
|
||||||
7
controllers/user_conntroller.go
Normal file
7
controllers/user_conntroller.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import "gorm.io/gorm"
|
||||||
|
|
||||||
|
type UserConntroller struct {
|
||||||
|
DB *gorm.DB
|
||||||
|
}
|
||||||
Binary file not shown.
18
go.mod
18
go.mod
@@ -2,29 +2,29 @@ module prada.ch
|
|||||||
|
|
||||||
go 1.25.4
|
go 1.25.4
|
||||||
|
|
||||||
require github.com/gofiber/fiber/v3 v3.0.0
|
require (
|
||||||
|
github.com/gofiber/fiber/v3 v3.0.0
|
||||||
|
golang.org/x/crypto v0.47.0
|
||||||
|
gorm.io/driver/sqlite v1.6.0
|
||||||
|
gorm.io/gorm v1.31.1
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
|
||||||
github.com/gofiber/schema v1.6.0 // indirect
|
github.com/gofiber/schema v1.6.0 // indirect
|
||||||
github.com/gofiber/utils/v2 v2.0.0 // indirect
|
github.com/gofiber/utils/v2 v2.0.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/klauspost/compress v1.18.3 // indirect
|
github.com/klauspost/compress v1.18.3 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||||
github.com/philhofer/fwd v1.2.0 // indirect
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
|
||||||
github.com/tinylib/msgp v1.6.3 // indirect
|
github.com/tinylib/msgp v1.6.3 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasthttp v1.69.0 // indirect
|
github.com/valyala/fasthttp v1.69.0 // indirect
|
||||||
golang.org/x/crypto v0.47.0 // indirect
|
|
||||||
golang.org/x/net v0.49.0 // indirect
|
golang.org/x/net v0.49.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/text v0.33.0 // indirect
|
||||||
modernc.org/libc v1.55.3 // indirect
|
|
||||||
modernc.org/mathutil v1.6.0 // indirect
|
|
||||||
modernc.org/memory v1.8.0 // indirect
|
|
||||||
modernc.org/sqlite v1.34.5 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
24
go.sum
24
go.sum
@@ -2,8 +2,6 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
|
|||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/gofiber/fiber/v3 v3.0.0 h1:GPeCG8X60L42wLKrzgeewDHBr6pE6veAvwaXsqD3Xjk=
|
github.com/gofiber/fiber/v3 v3.0.0 h1:GPeCG8X60L42wLKrzgeewDHBr6pE6veAvwaXsqD3Xjk=
|
||||||
@@ -14,20 +12,22 @@ github.com/gofiber/utils/v2 v2.0.0 h1:SCC3rpsEDWupFSHtc0RKxg/BKgV0s1qKfZg9Jv6D0s
|
|||||||
github.com/gofiber/utils/v2 v2.0.0/go.mod h1:xF9v89FfmbrYqI/bQUGN7gR8ZtXot2jxnZvmAUtiavE=
|
github.com/gofiber/utils/v2 v2.0.0/go.mod h1:xF9v89FfmbrYqI/bQUGN7gR8ZtXot2jxnZvmAUtiavE=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
|
||||||
github.com/shamaton/msgpack/v3 v3.0.0 h1:xl40uxWkSpwBCSTvS5wyXvJRsC6AcVcYeox9PspKiZg=
|
github.com/shamaton/msgpack/v3 v3.0.0 h1:xl40uxWkSpwBCSTvS5wyXvJRsC6AcVcYeox9PspKiZg=
|
||||||
github.com/shamaton/msgpack/v3 v3.0.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
|
github.com/shamaton/msgpack/v3 v3.0.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
@@ -53,11 +53,7 @@ golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
|||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
|
||||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
|
||||||
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
|
||||||
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
|
||||||
|
|||||||
814
main.go
814
main.go
@@ -2,29 +2,18 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"database/sql"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"html/template"
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
"net/mail"
|
|
||||||
"net/smtp"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"gorm.io/driver/sqlite"
|
||||||
_ "modernc.org/sqlite"
|
"gorm.io/gorm"
|
||||||
|
"prada.ch/controllers"
|
||||||
)
|
)
|
||||||
|
|
||||||
type landingData struct {
|
type landingData struct {
|
||||||
@@ -38,49 +27,6 @@ type resetPasswordPageData struct {
|
|||||||
Token string
|
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() {
|
func main() {
|
||||||
loadDotEnv(".env")
|
loadDotEnv(".env")
|
||||||
|
|
||||||
@@ -88,484 +34,61 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
defer db.Close()
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
|
||||||
mailConfig := getSMTPConfigFromEnv()
|
h := &controller{
|
||||||
|
db: db,
|
||||||
landingTemplate := template.Must(template.ParseFiles("templates/index.html"))
|
mailConfig: getSMTPConfigFromEnv(),
|
||||||
howToWorkTemplate := template.Must(template.ParseFiles("templates/howtowork.html"))
|
landingTemplate: template.Must(template.ParseFiles("templates/public/index.html")),
|
||||||
loginTemplate := template.Must(template.ParseFiles("templates/login.html"))
|
howToWorkTemplate: template.Must(template.ParseFiles("templates/public/howtowork.html")),
|
||||||
signupTemplate := template.Must(template.ParseFiles("templates/signup.html"))
|
loginTemplate: template.Must(template.ParseFiles("templates/public/login.html")),
|
||||||
forgotPasswordTemplate := template.Must(template.ParseFiles("templates/forgot_password.html"))
|
signupTemplate: template.Must(template.ParseFiles("templates/public/signup.html")),
|
||||||
resetPasswordTemplate := template.Must(template.ParseFiles("templates/reset_password.html"))
|
forgotPasswordTemplate: template.Must(template.ParseFiles("templates/public/forgot_password.html")),
|
||||||
assetVersion := buildAssetVersion("static/css/main.css", "static/js/i18n.js")
|
resetPasswordTemplate: template.Must(template.ParseFiles("templates/public/reset_password.html")),
|
||||||
|
welcomeTemplate: template.Must(template.ParseFiles("templates/private/welcome.html")),
|
||||||
|
assetVersion: buildAssetVersion("static/css/main.css", "static/js/i18n.js"),
|
||||||
|
}
|
||||||
|
h.subscribeController = &controllers.SubscribeController{
|
||||||
|
DB: h.db,
|
||||||
|
IsUniqueConstraint: isUniqueConstraint,
|
||||||
|
}
|
||||||
|
h.authController = &controllers.AuthController{
|
||||||
|
DB: h.db,
|
||||||
|
IsUniqueConstraint: isUniqueConstraint,
|
||||||
|
AppBaseURL: getAppBaseURL,
|
||||||
|
MailConfigured: h.mailConfig.isConfigured,
|
||||||
|
SendPasswordResetEmail: func(recipientEmail, resetURL string) error {
|
||||||
|
return sendPasswordResetEmail(h.mailConfig, recipientEmail, resetURL)
|
||||||
|
},
|
||||||
|
SendEmailVerificationMail: func(recipientEmail, verifyURL string) error {
|
||||||
|
return sendEmailVerificationEmail(h.mailConfig, recipientEmail, verifyURL)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
app.Get("/static/*", func(c fiber.Ctx) error {
|
app.Get("/static/*", h.staticFile)
|
||||||
relativePath := filepath.Clean(c.Params("*"))
|
app.Post("/api/subscribe", h.subscribeController.Subscribe)
|
||||||
if relativePath == "." || strings.HasPrefix(relativePath, "..") {
|
app.Post("/api/auth/register", h.authController.Register)
|
||||||
return c.SendStatus(fiber.StatusNotFound)
|
app.Post("/api/auth/login", h.authController.Login)
|
||||||
}
|
app.Post("/api/auth/logout", h.authController.Logout)
|
||||||
return c.SendFile(filepath.Join("static", relativePath))
|
app.Get("/api/auth/me", h.authController.Me)
|
||||||
})
|
app.Post("/api/auth/forgot-password", h.authController.ForgotPassword)
|
||||||
app.Post("/api/subscribe", func(c fiber.Ctx) error {
|
app.Post("/api/auth/resend-verification", h.authController.ResendVerification)
|
||||||
var req subscribeRequest
|
app.Post("/api/auth/reset-password", h.authController.ResetPassword)
|
||||||
if err := json.Unmarshal(c.Body(), &req); err != nil {
|
app.Get("/auth/verify-email", h.authController.VerifyEmail)
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid payload"})
|
app.Get("/", h.home)
|
||||||
}
|
app.Get("/reset-password", h.resetPasswordPage)
|
||||||
|
app.Get("/howtowork", h.howToWorkPage)
|
||||||
|
app.Get("/login", h.loginPage)
|
||||||
|
app.Get("/signup", h.signupPage)
|
||||||
|
app.Get("/forgot-password", h.forgotPasswordPage)
|
||||||
|
app.Get("/welcome", h.welcomePage)
|
||||||
|
|
||||||
email := strings.ToLower(strings.TrimSpace(req.Email))
|
log.Fatal(app.Listen(":6082"))
|
||||||
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 {
|
func buildAssetVersion(paths ...string) string {
|
||||||
@@ -581,21 +104,20 @@ func buildAssetVersion(paths ...string) string {
|
|||||||
return hex.EncodeToString(hasher.Sum(nil))[:12]
|
return hex.EncodeToString(hasher.Sum(nil))[:12]
|
||||||
}
|
}
|
||||||
|
|
||||||
func hashEmail(email string) string {
|
func initDB(path string) (*gorm.DB, error) {
|
||||||
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 {
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := sql.Open("sqlite", path)
|
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := db.Exec("PRAGMA foreign_keys = ON;").Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
schema := `
|
schema := `
|
||||||
CREATE TABLE IF NOT EXISTS subscribers (
|
CREATE TABLE IF NOT EXISTS subscribers (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -606,8 +128,7 @@ func initDB(path string) (*sql.DB, error) {
|
|||||||
browser_data TEXT,
|
browser_data TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);`
|
);`
|
||||||
if _, err := db.Exec(schema); err != nil {
|
if err := db.Exec(schema).Error; err != nil {
|
||||||
db.Close()
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,8 +141,7 @@ func initDB(path string) (*sql.DB, error) {
|
|||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);`
|
);`
|
||||||
if _, err := db.Exec(userSchema); err != nil {
|
if err := db.Exec(userSchema).Error; err != nil {
|
||||||
db.Close()
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,8 +154,7 @@ func initDB(path string) (*sql.DB, error) {
|
|||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);`
|
);`
|
||||||
if _, err := db.Exec(sessionSchema); err != nil {
|
if err := db.Exec(sessionSchema).Error; err != nil {
|
||||||
db.Close()
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,8 +168,7 @@ func initDB(path string) (*sql.DB, error) {
|
|||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);`
|
);`
|
||||||
if _, err := db.Exec(resetSchema); err != nil {
|
if err := db.Exec(resetSchema).Error; err != nil {
|
||||||
db.Close()
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -664,8 +182,7 @@ func initDB(path string) (*sql.DB, error) {
|
|||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);`
|
);`
|
||||||
if _, err := db.Exec(verifySchema); err != nil {
|
if err := db.Exec(verifySchema).Error; err != nil {
|
||||||
db.Close()
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -679,75 +196,6 @@ func isUniqueConstraint(err error) bool {
|
|||||||
return strings.Contains(strings.ToLower(err.Error()), "unique")
|
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) {
|
func loadDotEnv(path string) {
|
||||||
file, err := os.Open(path)
|
file, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -775,135 +223,3 @@ func loadDotEnv(path string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSMTPConfigFromEnv() smtpConfig {
|
|
||||||
port := 587
|
|
||||||
if rawPort := strings.TrimSpace(os.Getenv("STARTTLS_PORT")); rawPort != "" {
|
|
||||||
if p, err := strconv.Atoi(rawPort); err == nil {
|
|
||||||
port = p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return smtpConfig{
|
|
||||||
Host: strings.TrimSpace(os.Getenv("SMTP")),
|
|
||||||
Port: port,
|
|
||||||
User: strings.TrimSpace(os.Getenv("SMTP_USER")),
|
|
||||||
Password: strings.TrimSpace(os.Getenv("SMTP_PASSWORD")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s smtpConfig) isConfigured() bool {
|
|
||||||
return s.Host != "" && s.User != "" && s.Password != "" && s.Port > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAppBaseURL() string {
|
|
||||||
base := strings.TrimSpace(os.Getenv("SITE_URL"))
|
|
||||||
if base == "" {
|
|
||||||
base = strings.TrimSpace(os.Getenv("APP_BASE_URL"))
|
|
||||||
}
|
|
||||||
if base == "" {
|
|
||||||
return "http://localhost:6081"
|
|
||||||
}
|
|
||||||
return strings.TrimRight(base, "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendPasswordResetEmail(config smtpConfig, recipientEmail, resetURL string) error {
|
|
||||||
body, err := renderTransactionalTemplate("password_reset.html", map[string]string{
|
|
||||||
"ActionURL": resetURL,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return sendHTMLEmail(config, recipientEmail, "Bag Exchange - Password reset", body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendEmailVerificationEmail(config smtpConfig, recipientEmail, verifyURL string) error {
|
|
||||||
body, err := renderTransactionalTemplate("verify_email.html", map[string]string{
|
|
||||||
"ActionURL": verifyURL,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return sendHTMLEmail(config, recipientEmail, "Bag Exchange - Verify your email", body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendHTMLEmail(config smtpConfig, recipientEmail, subject, htmlBody string) error {
|
|
||||||
if isDevEmailMode() {
|
|
||||||
return saveDevEmail(recipientEmail, subject, htmlBody)
|
|
||||||
}
|
|
||||||
|
|
||||||
auth := smtp.PlainAuth("", config.User, config.Password, config.Host)
|
|
||||||
message := "From: " + config.User + "\r\n" +
|
|
||||||
"To: " + recipientEmail + "\r\n" +
|
|
||||||
"Subject: " + subject + "\r\n" +
|
|
||||||
"MIME-Version: 1.0\r\n" +
|
|
||||||
"Content-Type: text/html; charset=UTF-8\r\n\r\n" +
|
|
||||||
htmlBody
|
|
||||||
return smtp.SendMail(fmt.Sprintf("%s:%d", config.Host, config.Port), auth, config.User, []string{recipientEmail}, []byte(message))
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderTransactionalTemplate(templateName string, data any) (string, error) {
|
|
||||||
tmpl, err := template.ParseFiles(filepath.Join("templates", "transactionalMails", templateName))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
var out bytes.Buffer
|
|
||||||
if err := tmpl.Execute(&out, data); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return out.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func createEmailVerificationToken(db *sql.DB, userID int64) (string, error) {
|
|
||||||
token, err := generateSessionToken()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
expiresAt := time.Now().Add(time.Hour * emailVerificationDurationHours).Unix()
|
|
||||||
_, err = db.Exec(`
|
|
||||||
INSERT INTO email_verification_tokens (user_id, token_hash, expires_at)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
`, userID, hashToken(token), expiresAt)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return token, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isDevEmailMode() bool {
|
|
||||||
return strings.EqualFold(strings.TrimSpace(os.Getenv("EMAIL_MODE")), "dev")
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveDevEmail(recipientEmail, subject, htmlBody string) error {
|
|
||||||
const dir = "devEmails"
|
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
filename := fmt.Sprintf("%s_%s.html", time.Now().UTC().Format("20060102_150405"), sanitizeFilename(subject))
|
|
||||||
path := filepath.Join(dir, filename)
|
|
||||||
|
|
||||||
payload := "<!--\n" +
|
|
||||||
"To: " + recipientEmail + "\n" +
|
|
||||||
"Subject: " + subject + "\n" +
|
|
||||||
"GeneratedAtUTC: " + time.Now().UTC().Format(time.RFC3339) + "\n" +
|
|
||||||
"-->\n" + htmlBody
|
|
||||||
|
|
||||||
return os.WriteFile(path, []byte(payload), 0o644)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sanitizeFilename(value string) string {
|
|
||||||
var b strings.Builder
|
|
||||||
for _, r := range strings.ToLower(strings.TrimSpace(value)) {
|
|
||||||
switch {
|
|
||||||
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
|
|
||||||
b.WriteRune(r)
|
|
||||||
case r == ' ' || r == '-' || r == '_':
|
|
||||||
b.WriteRune('_')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
name := strings.Trim(b.String(), "_")
|
|
||||||
if name == "" {
|
|
||||||
return "email"
|
|
||||||
}
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|||||||
71
models.go
Normal file
71
models.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Subscriber maps the subscribers table.
|
||||||
|
type Subscriber struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
Email string `gorm:"column:email;uniqueIndex;not null"`
|
||||||
|
IPAddress string `gorm:"column:ip_address;not null"`
|
||||||
|
UserAgent string `gorm:"column:user_agent;not null"`
|
||||||
|
AcceptLanguage string `gorm:"column:accept_language"`
|
||||||
|
BrowserData string `gorm:"column:browser_data"`
|
||||||
|
CreatedAt string `gorm:"column:created_at;not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Subscriber) TableName() string {
|
||||||
|
return "subscribers"
|
||||||
|
}
|
||||||
|
|
||||||
|
// User maps the users table.
|
||||||
|
type User struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
Email string `gorm:"column:email;uniqueIndex;not null"`
|
||||||
|
PasswordHash string `gorm:"column:password_hash;not null"`
|
||||||
|
EmailVerified int `gorm:"column:email_verified;not null;default:0"`
|
||||||
|
CreatedAt string `gorm:"column:created_at;not null"`
|
||||||
|
UpdatedAt string `gorm:"column:updated_at;not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (User) TableName() string {
|
||||||
|
return "users"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session maps the sessions table.
|
||||||
|
type Session struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
UserID int64 `gorm:"column:user_id;not null;index"`
|
||||||
|
TokenHash string `gorm:"column:token_hash;uniqueIndex;not null"`
|
||||||
|
ExpiresAt int64 `gorm:"column:expires_at;not null"`
|
||||||
|
CreatedAt string `gorm:"column:created_at;not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Session) TableName() string {
|
||||||
|
return "sessions"
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordResetToken maps the password_reset_tokens table.
|
||||||
|
type PasswordResetToken struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
UserID int64 `gorm:"column:user_id;not null;index"`
|
||||||
|
TokenHash string `gorm:"column:token_hash;uniqueIndex;not null"`
|
||||||
|
ExpiresAt int64 `gorm:"column:expires_at;not null"`
|
||||||
|
UsedAt *int64 `gorm:"column:used_at"`
|
||||||
|
CreatedAt string `gorm:"column:created_at;not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (PasswordResetToken) TableName() string {
|
||||||
|
return "password_reset_tokens"
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmailVerificationToken maps the email_verification_tokens table.
|
||||||
|
type EmailVerificationToken struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
UserID int64 `gorm:"column:user_id;not null;index"`
|
||||||
|
TokenHash string `gorm:"column:token_hash;uniqueIndex;not null"`
|
||||||
|
ExpiresAt int64 `gorm:"column:expires_at;not null"`
|
||||||
|
UsedAt *int64 `gorm:"column:used_at"`
|
||||||
|
CreatedAt string `gorm:"column:created_at;not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (EmailVerificationToken) TableName() string {
|
||||||
|
return "email_verification_tokens"
|
||||||
|
}
|
||||||
136
smtp.go
Normal file
136
smtp.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/smtp"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type smtpConfig struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSMTPConfigFromEnv() smtpConfig {
|
||||||
|
port := 587
|
||||||
|
if rawPort := strings.TrimSpace(os.Getenv("STARTTLS_PORT")); rawPort != "" {
|
||||||
|
if p, err := strconv.Atoi(rawPort); err == nil {
|
||||||
|
port = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return smtpConfig{
|
||||||
|
Host: strings.TrimSpace(os.Getenv("SMTP")),
|
||||||
|
Port: port,
|
||||||
|
User: strings.TrimSpace(os.Getenv("SMTP_USER")),
|
||||||
|
Password: strings.TrimSpace(os.Getenv("SMTP_PASSWORD")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s smtpConfig) isConfigured() bool {
|
||||||
|
return s.Host != "" && s.User != "" && s.Password != "" && s.Port > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAppBaseURL() string {
|
||||||
|
base := strings.TrimSpace(os.Getenv("SITE_URL"))
|
||||||
|
if base == "" {
|
||||||
|
base = strings.TrimSpace(os.Getenv("APP_BASE_URL"))
|
||||||
|
}
|
||||||
|
if base == "" {
|
||||||
|
return "http://localhost:6081"
|
||||||
|
}
|
||||||
|
return strings.TrimRight(base, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendPasswordResetEmail(config smtpConfig, recipientEmail, resetURL string) error {
|
||||||
|
body, err := renderTransactionalTemplate("password_reset.html", map[string]string{
|
||||||
|
"ActionURL": resetURL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return sendHTMLEmail(config, recipientEmail, "Bag Exchange - Password reset", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendEmailVerificationEmail(config smtpConfig, recipientEmail, verifyURL string) error {
|
||||||
|
body, err := renderTransactionalTemplate("verify_email.html", map[string]string{
|
||||||
|
"ActionURL": verifyURL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return sendHTMLEmail(config, recipientEmail, "Bag Exchange - Verify your email", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendHTMLEmail(config smtpConfig, recipientEmail, subject, htmlBody string) error {
|
||||||
|
if isDevEmailMode() {
|
||||||
|
return saveDevEmail(recipientEmail, subject, htmlBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
auth := smtp.PlainAuth("", config.User, config.Password, config.Host)
|
||||||
|
message := "From: " + config.User + "\r\n" +
|
||||||
|
"To: " + recipientEmail + "\r\n" +
|
||||||
|
"Subject: " + subject + "\r\n" +
|
||||||
|
"MIME-Version: 1.0\r\n" +
|
||||||
|
"Content-Type: text/html; charset=UTF-8\r\n\r\n" +
|
||||||
|
htmlBody
|
||||||
|
return smtp.SendMail(fmt.Sprintf("%s:%d", config.Host, config.Port), auth, config.User, []string{recipientEmail}, []byte(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderTransactionalTemplate(templateName string, data any) (string, error) {
|
||||||
|
tmpl, err := template.ParseFiles(filepath.Join("templates", "transactionalMails", templateName))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&out, data); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return out.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDevEmailMode() bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(os.Getenv("EMAIL_MODE")), "dev")
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveDevEmail(recipientEmail, subject, htmlBody string) error {
|
||||||
|
const dir = "devEmails"
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("%s_%s.html", time.Now().UTC().Format("20060102_150405"), sanitizeFilename(subject))
|
||||||
|
path := filepath.Join(dir, filename)
|
||||||
|
|
||||||
|
payload := "<!--\n" +
|
||||||
|
"To: " + recipientEmail + "\n" +
|
||||||
|
"Subject: " + subject + "\n" +
|
||||||
|
"GeneratedAtUTC: " + time.Now().UTC().Format(time.RFC3339) + "\n" +
|
||||||
|
"-->\n" + htmlBody
|
||||||
|
|
||||||
|
return os.WriteFile(path, []byte(payload), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeFilename(value string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
switch {
|
||||||
|
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r == ' ' || r == '-' || r == '_':
|
||||||
|
b.WriteRune('_')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name := strings.Trim(b.String(), "_")
|
||||||
|
if name == "" {
|
||||||
|
return "email"
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
19
templates/private/welcome.html
Normal file
19
templates/private/welcome.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Bag Exchange | Welcome</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-gradient-to-br from-amber-50 via-orange-50 to-stone-200 text-zinc-800">
|
||||||
|
<main class="mx-auto grid min-h-screen w-full max-w-2xl place-items-center px-4 py-8">
|
||||||
|
<section class="w-full max-w-xl rounded-2xl border border-zinc-300/70 bg-amber-50/80 p-6 shadow-2xl shadow-stone-700/10 backdrop-blur-sm text-center">
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight">Welcome {{.Email}}</h1>
|
||||||
|
<p class="mt-4 text-sm">
|
||||||
|
<a class="text-emerald-900 underline decoration-1 underline-offset-2" href="/">Back to homepage</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
try { payload = await response.json(); } catch (e) {}
|
try { payload = await response.json(); } catch (e) {}
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
window.location.href = "/?lang=" + encodeURIComponent(i18n.getLanguage());
|
window.location.href = "/welcome?lang=" + encodeURIComponent(i18n.getLanguage());
|
||||||
} else if (payload.error === "email_not_verified") {
|
} else if (payload.error === "email_not_verified") {
|
||||||
showMessage("error", i18n.t("msgNotVerified"));
|
showMessage("error", i18n.t("msgNotVerified"));
|
||||||
} else {
|
} else {
|
||||||
Reference in New Issue
Block a user