aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2025-07-13 14:22:20 +0530
committerBobby <[email protected]>2025-07-13 14:22:20 +0530
commit3d7f8602d45583f25e2428bf6f8123453646dc08 (patch)
treeecd707d298099ae9fda55efc3f0d1daf48f7b6e9
parentbf112649d039f8f02e2135a74d8b506f7c31c784 (diff)
downloadimageboard-3d7f8602d45583f25e2428bf6f8123453646dc08.tar.xz
imageboard-3d7f8602d45583f25e2428bf6f8123453646dc08.zip
registration controllers and email sending support
-rw-r--r--config/types.go1
-rw-r--r--controllers/constants.go8
-rw-r--r--controllers/home.go4
-rw-r--r--controllers/login.go24
-rw-r--r--controllers/logout.go23
-rw-r--r--controllers/register.go75
-rw-r--r--database/database.go1
-rw-r--r--database/tokens.go65
-rw-r--r--database/user.go12
-rw-r--r--models/tokens.go34
-rw-r--r--models/user.go19
-rw-r--r--processors/processors.go1
-rw-r--r--processors/user.go43
-rw-r--r--router/routes.go12
-rw-r--r--session/session.go1
-rw-r--r--static/css/main.css9
-rw-r--r--templates/email/verification.html75
-rw-r--r--templates/register.django11
-rw-r--r--utils/auth/auth.go26
-rw-r--r--utils/email/email.go124
-rw-r--r--utils/validators/tokens.go14
21 files changed, 549 insertions, 33 deletions
diff --git a/config/types.go b/config/types.go
index c8bdd75..129e3d2 100644
--- a/config/types.go
+++ b/config/types.go
@@ -6,6 +6,7 @@ type ServerConfig struct {
Host string `env:"SERVER_HOST" default:"localhost"`
Port int `env:"SERVER_PORT" default:"8080"`
AppName string `env:"APP_NAME" default:"ImageBoard"`
+ AppBaseURL string `env:"APP_BASE_URL" default:"http://localhost:8080"`
AppSecret string `env:"APP_SECRET" default:"default_secret"`
IsDevMode bool `env:"DEV_MODE" default:"true"`
MinPasswordLength int `env:"MIN_PASSWORD_LENGTH" default:"8"`
diff --git a/controllers/constants.go b/controllers/constants.go
index 95d75c1..021eeb9 100644
--- a/controllers/constants.go
+++ b/controllers/constants.go
@@ -32,6 +32,10 @@ const (
ERR_LOGIN_INVALID_CREDENTIALS = `The credentials you provided are incorrect. Did you <a href="` + URL_FORGOT_PASSWORD + `">forget your password</a>?`
ERR_ACCOUNT_DISABLED = `Your account is disabled or banned. You can reach out to support for assistance.`
ERR_ACCOUNT_UNABLE_TO_LOGIN = `You cannot log in at this time. Verify your email or contact support. If you misplaced your verification email, you can <a href="` + URL_RESEND_VERIFICATION + `">request a new one</a>.`
- ERR_SESSION_FAILED_TO_CREATE = "Failed to create session. Please try again later."
- ERR_SESSION_FAILED_TO_SAVE = "Failed to save session. Please try again later."
+ ERR_PASSWORD_MISMATCH = "Entered passwords do not match. Ensure both fields are identical."
+ ERR_SESSION_FAILED_TO_CREATE = "Server failed to create a session. If this issue persists, contact support."
+ ERR_SESSION_FAILED_TO_SAVE = "Server failed to save session data. If this issue persists, contact support."
+
+ // Success messages
+ SUCCESS_USER_REGISTERED = "Your account has been created successfully. A verification email has been sent to your email address. You will only be able to log in after verifying your email. If you did not receive the email, you can <a href=\"" + URL_RESEND_VERIFICATION + "\">request a new one</a>."
)
diff --git a/controllers/home.go b/controllers/home.go
index 6758a4b..186ef24 100644
--- a/controllers/home.go
+++ b/controllers/home.go
@@ -7,6 +7,6 @@ import (
)
func HomePageController(ctx *fiber.Ctx) error {
- ctx.Locals("Title", "Home Page")
- return shortcuts.Render(ctx, "home", nil)
+ ctx.Locals("Title", PT_HOME)
+ return shortcuts.Render(ctx, TEMPLATE_HOME, nil)
}
diff --git a/controllers/login.go b/controllers/login.go
index 12262e3..6eb4996 100644
--- a/controllers/login.go
+++ b/controllers/login.go
@@ -3,17 +3,15 @@ package controllers
import (
"imageboard/database"
"imageboard/session"
+ "imageboard/utils/auth"
"imageboard/utils/shortcuts"
"github.com/gofiber/fiber/v2"
)
-func getRedirectURL(ctx *fiber.Ctx) string {
- referer := ctx.Get("Referer")
- if referer != "" && referer != ctx.BaseURL()+URL_LOGIN && referer != ctx.BaseURL()+URL_REGISTER {
- return referer
- }
- return URL_HOME
+type LoginForm struct {
+ Username string `json:"username" form:"username"`
+ Password string `json:"password" form:"password"`
}
func renderLoginError(ctx *fiber.Ctx, errorMsg string, statusCode int) error {
@@ -25,11 +23,9 @@ func renderLoginError(ctx *fiber.Ctx, errorMsg string, statusCode int) error {
func LoginPageController(ctx *fiber.Ctx) error {
ctx.Locals("Title", PT_LOGIN)
- sess, err := session.Store.Get(ctx)
- if err == nil {
- if userID, ok := sess.Get("user_id").(int); ok && userID != 0 {
- return ctx.Redirect(getRedirectURL(ctx), fiber.StatusSeeOther)
- }
+
+ if auth.IsAuthenticated(ctx) {
+ return ctx.Redirect(auth.GetRedirectURL(ctx), fiber.StatusSeeOther)
}
return shortcuts.Render(ctx, TEMPLATE_LOGIN, nil)
@@ -37,10 +33,6 @@ func LoginPageController(ctx *fiber.Ctx) error {
func LoginPostController(ctx *fiber.Ctx) error {
ctx.Locals("Title", PT_LOGIN)
- type LoginForm struct {
- Username string `json:"username" form:"username"`
- Password string `json:"password" form:"password"`
- }
var form LoginForm
var err error
@@ -78,5 +70,5 @@ func LoginPostController(ctx *fiber.Ctx) error {
user.UpdateLastUserLogin(database.DB)
user.UpdateLastUserActivity(database.DB)
- return ctx.Redirect(getRedirectURL(ctx), fiber.StatusSeeOther)
+ return ctx.Redirect(auth.GetRedirectURL(ctx), fiber.StatusSeeOther)
}
diff --git a/controllers/logout.go b/controllers/logout.go
new file mode 100644
index 0000000..20c280d
--- /dev/null
+++ b/controllers/logout.go
@@ -0,0 +1,23 @@
+package controllers
+
+import (
+ "imageboard/session"
+ "imageboard/utils/auth"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func LogoutController(ctx *fiber.Ctx) error {
+ sess, err := session.Store.Get(ctx)
+ if err != nil {
+ return ctx.Redirect(auth.GetRedirectURL(ctx), fiber.StatusSeeOther)
+ }
+
+ if err := sess.Destroy(); err != nil {
+ sess.Delete("user_id")
+ sess.Delete("username")
+ sess.Save()
+ }
+
+ return ctx.Redirect(auth.GetRedirectURL(ctx), fiber.StatusSeeOther)
+}
diff --git a/controllers/register.go b/controllers/register.go
index d16db9d..acadbc5 100644
--- a/controllers/register.go
+++ b/controllers/register.go
@@ -1,12 +1,83 @@
package controllers
import (
+ "imageboard/database"
+ "imageboard/models"
+ "imageboard/utils/auth"
+ "imageboard/utils/email"
"imageboard/utils/shortcuts"
+ "log"
+ "strings"
"github.com/gofiber/fiber/v2"
)
+type RegisterForm struct {
+ Username string `json:"username" form:"username"`
+ Email string `json:"email" form:"email"`
+ Password string `json:"password" form:"password"`
+ ConfirmPassword string `json:"confirm_password" form:"confirm_password"`
+}
+
+func renderRegisterError(ctx *fiber.Ctx, errorMsg string, statusCode int) error {
+ return shortcuts.RenderWithStatus(ctx, TEMPLATE_REGISTER, fiber.Map{
+ "Error": errorMsg,
+ "Username": ctx.FormValue("username"),
+ "Email": ctx.FormValue("email"),
+ }, statusCode)
+}
+
func RegisterPageController(ctx *fiber.Ctx) error {
- ctx.Locals("Title", "Register")
- return shortcuts.Render(ctx, "register", nil)
+ ctx.Locals("Title", PT_REGISTER)
+
+ if auth.IsAuthenticated(ctx) {
+ return ctx.Redirect(auth.GetRedirectURL(ctx), fiber.StatusSeeOther)
+ }
+
+ return shortcuts.Render(ctx, TEMPLATE_REGISTER, nil)
+}
+
+func RegisterPostController(ctx *fiber.Ctx) error {
+ ctx.Locals("Title", PT_REGISTER)
+
+ if auth.IsAuthenticated(ctx) {
+ return ctx.Redirect(auth.GetRedirectURL(ctx), fiber.StatusSeeOther)
+ }
+
+ var form RegisterForm
+ if err := ctx.BodyParser(&form); err != nil {
+ return renderRegisterError(ctx, ERR_INVALID_FORM_DATA, fiber.StatusBadRequest)
+ }
+
+ if form.Password != form.ConfirmPassword {
+ return renderRegisterError(ctx, ERR_PASSWORD_MISMATCH, fiber.StatusBadRequest)
+ }
+
+ user := &models.User{
+ Username: form.Username,
+ Email: form.Email,
+ Password: form.Password,
+ }
+
+ if err := database.CreateUser(user); err != nil {
+ var statusCode int
+ if strings.Contains(err.Error(), "username") {
+ statusCode = fiber.StatusConflict
+ } else if strings.Contains(err.Error(), "email") {
+ statusCode = fiber.StatusBadRequest
+ } else {
+ statusCode = fiber.StatusInternalServerError
+ }
+
+ return renderRegisterError(ctx, "Failed to create user: "+err.Error(), statusCode)
+ }
+
+ if err := email.SendVerificationEmail(user); err != nil {
+ log.Printf("Failed to send verification email: %v", err)
+ return renderRegisterError(ctx, "User created but failed to send verification email", fiber.StatusInternalServerError)
+ }
+
+ return shortcuts.Render(ctx, TEMPLATE_REGISTER, fiber.Map{
+ "Success": SUCCESS_USER_REGISTERED,
+ })
}
diff --git a/database/database.go b/database/database.go
index d284c0f..dedae59 100644
--- a/database/database.go
+++ b/database/database.go
@@ -64,6 +64,7 @@ func autoMigrate() error {
&models.ImageSize{},
&models.Tag{},
&models.Comment{},
+ &models.EmailToken{},
)
}
diff --git a/database/tokens.go b/database/tokens.go
new file mode 100644
index 0000000..ddbc1f6
--- /dev/null
+++ b/database/tokens.go
@@ -0,0 +1,65 @@
+package database
+
+import (
+ "fmt"
+ "imageboard/models"
+ "imageboard/utils/validators"
+ "time"
+)
+
+func GenerateEmailToken(userID int, tokenType models.EmailTokenType) (*models.EmailToken, error) {
+ var existingToken models.EmailToken
+ if err := DB.Where("user_id = ? AND type = ?", userID, tokenType).First(&existingToken).Error; err == nil {
+ if err := DB.Delete(&existingToken).Error; err != nil {
+ return nil, err
+ }
+ }
+
+ tokenValue, err := validators.GenerateRandomToken()
+ if err != nil {
+ return nil, err
+ }
+
+ var expirationDuration time.Duration
+ switch tokenType {
+ case models.EmailTokenTypeVerification:
+ expirationDuration = 24 * time.Hour
+ case models.EmailTokenTypePasswordReset:
+ expirationDuration = 1 * time.Hour
+ case models.EmailTokenTypeChangeEmail:
+ expirationDuration = 1 * time.Hour
+ default:
+ expirationDuration = 1 * time.Hour
+ }
+
+ token := &models.EmailToken{
+ UserID: uint(userID),
+ Token: tokenValue,
+ Type: tokenType,
+ ExpiresAt: time.Now().Add(expirationDuration),
+ }
+
+ if err := DB.Create(token).Error; err != nil {
+ return nil, err
+ }
+
+ return token, nil
+}
+
+func VerifyToken(userID int, token string, tokenType models.EmailTokenType) (*models.EmailToken, error) {
+ var emailToken models.EmailToken
+ if err := DB.Where("user_id = ? AND token = ? AND type = ?", userID, token, tokenType).First(&emailToken).Error; err != nil {
+ return nil, err
+ }
+
+ if !emailToken.IsValid() {
+ return nil, fmt.Errorf("token is invalid or expired")
+ }
+
+ emailToken.MarkAsUsed()
+ if err := DB.Save(&emailToken).Error; err != nil {
+ return nil, fmt.Errorf("failed to mark token as used: %w", err)
+ }
+
+ return &emailToken, nil
+}
diff --git a/database/user.go b/database/user.go
index c512038..4fe7e18 100644
--- a/database/user.go
+++ b/database/user.go
@@ -9,3 +9,15 @@ func GetUserByUsername(username string) (*models.User, error) {
}
return &user, nil
}
+
+func GetUserByID(userID uint) (*models.User, error) {
+ var user models.User
+ if err := DB.Where("id = ?", userID).First(&user).Error; err != nil {
+ return nil, err
+ }
+ return &user, nil
+}
+
+func CreateUser(user *models.User) error {
+ return DB.Create(user).Error
+}
diff --git a/models/tokens.go b/models/tokens.go
new file mode 100644
index 0000000..c53ea2e
--- /dev/null
+++ b/models/tokens.go
@@ -0,0 +1,34 @@
+package models
+
+import (
+ "time"
+
+ "gorm.io/gorm"
+)
+
+type EmailToken struct {
+ gorm.Model
+ UserID uint `gorm:"not null;index" json:"user_id"`
+ Token string `gorm:"uniqueIndex;not null;size:64" json:"token"`
+ Type EmailTokenType `gorm:"not null;size:20" json:"type"`
+ ExpiresAt time.Time `gorm:"not null" json:"expires_at"`
+ UsedAt *time.Time `gorm:"default:null" json:"used_at"`
+ User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
+}
+
+func (et *EmailToken) IsExpired() bool {
+ return time.Now().After(et.ExpiresAt)
+}
+
+func (et *EmailToken) IsUsed() bool {
+ return et.UsedAt != nil
+}
+
+func (et *EmailToken) IsValid() bool {
+ return !et.IsExpired() && !et.IsUsed()
+}
+
+func (et *EmailToken) MarkAsUsed() {
+ now := time.Now()
+ et.UsedAt = &now
+}
diff --git a/models/user.go b/models/user.go
index 5607e68..3b92077 100644
--- a/models/user.go
+++ b/models/user.go
@@ -72,13 +72,27 @@ func (u *User) BeforeCreate(tx *gorm.DB) error {
var userCount int64
if err := tx.Model(&User{}).Where("is_deleted = ?", false).Count(&userCount).Error; err != nil {
- return err
+ return fmt.Errorf("failed to count existing users: %v", err)
}
if userCount == 0 {
u.Level = UserLevelSuperAdmin // First user becomes Super Admin
}
+ if len(u.Password) < config.Server.MinPasswordLength {
+ return fmt.Errorf("password must be at least %d characters long", config.Server.MinPasswordLength)
+ }
+ if len(u.Password) > 255 {
+ return fmt.Errorf("password must not exceed 255 characters")
+ }
+
+ hashedPassword, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
+ if err != nil {
+ return err
+ }
+
+ u.Password = string(hashedPassword)
+
return nil
}
@@ -93,16 +107,13 @@ func (u *User) SetPassword(password string) error {
if len(password) < config.Server.MinPasswordLength {
return fmt.Errorf("password must be at least %d characters long", config.Server.MinPasswordLength)
}
-
if len(password) > 255 {
return fmt.Errorf("password must not exceed 255 characters")
}
-
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
-
u.Password = string(hashedPassword)
return nil
}
diff --git a/processors/processors.go b/processors/processors.go
index bbd23fe..db42480 100644
--- a/processors/processors.go
+++ b/processors/processors.go
@@ -4,6 +4,7 @@ import "github.com/gofiber/fiber/v2"
func Initialize(app *fiber.App) {
app.Use(RequestContextProcessor)
+ app.Use(UserContextProcessor)
app.Use(MetaContextProcessor)
app.Use(SidebarContextProcessor)
app.Use(PreferencesContextProcessor)
diff --git a/processors/user.go b/processors/user.go
new file mode 100644
index 0000000..b875c89
--- /dev/null
+++ b/processors/user.go
@@ -0,0 +1,43 @@
+package processors
+
+import (
+ "imageboard/database"
+ "imageboard/models"
+ "imageboard/session"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func UserContextProcessor(ctx *fiber.Ctx) error {
+ var user *models.User
+
+ sess, err := session.Store.Get(ctx)
+ if err == nil {
+ var userID uint
+ if id := sess.Get("user_id"); id != nil {
+ switch v := id.(type) {
+ case uint:
+ userID = v
+ case int:
+ userID = uint(v)
+ case int64:
+ userID = uint(v)
+ case float64:
+ userID = uint(v)
+ }
+ }
+
+ if userID != 0 {
+ dbUser, err := database.GetUserByID(userID)
+ if err == nil && dbUser != nil {
+ dbUser.UpdateLastUserActivity(database.DB)
+ user = dbUser
+ }
+ }
+ }
+
+ ctx.Locals("User", user)
+ ctx.Locals("IsAuthenticated", user != nil)
+
+ return ctx.Next()
+}
diff --git a/router/routes.go b/router/routes.go
index d72e990..c522599 100644
--- a/router/routes.go
+++ b/router/routes.go
@@ -9,8 +9,6 @@ import (
func Initialize(router *fiber.App) {
main := router.Group("/")
main.Get("/", controllers.HomePageController)
- main.Get("/register", controllers.RegisterPageController)
- main.Get("/preferences", controllers.PreferencesPageController)
posts := router.Group("/posts")
posts.Get("/", controllers.PostsController)
@@ -19,5 +17,15 @@ func Initialize(router *fiber.App) {
login.Get("/", controllers.LoginPageController)
login.Post("/", controllers.LoginPostController)
+ logout := router.Group("/logout")
+ logout.Get("/", controllers.LogoutController)
+
+ register := router.Group("/register")
+ register.Get("/", controllers.RegisterPageController)
+ register.Post("/", controllers.RegisterPostController)
+
+ preferences := router.Group("/preferences")
+ preferences.Get("/", controllers.PreferencesPageController)
+
router.Use(controllers.NotFoundController)
}
diff --git a/session/session.go b/session/session.go
index ed89c5a..ac99300 100644
--- a/session/session.go
+++ b/session/session.go
@@ -25,7 +25,6 @@ func init() {
Storage: storage,
Expiration: config.Session.Expiration,
KeyLookup: "cookie:" + config.Session.CookieName,
- CookieName: config.Session.CookieName,
CookieDomain: config.Session.CookieDomain,
CookiePath: config.Session.CookiePath,
CookieSecure: config.Session.CookieSecure,
diff --git a/static/css/main.css b/static/css/main.css
index af99d59..55b4fa1 100644
--- a/static/css/main.css
+++ b/static/css/main.css
@@ -363,4 +363,13 @@ footer::before {
padding: 8px;
margin-bottom: 16px;
text-align: center;
+}
+
+.success {
+ color: #ccffcc;
+ background-color: #003300;
+ border: 1px solid #00ff00;
+ padding: 8px;
+ margin-bottom: 16px;
+ text-align: center;
} \ No newline at end of file
diff --git a/templates/email/verification.html b/templates/email/verification.html
new file mode 100644
index 0000000..bef032c
--- /dev/null
+++ b/templates/email/verification.html
@@ -0,0 +1,75 @@
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Verify your email</title>
+ <style>
+ body {
+ background: #000033;
+ color: #ccccff;
+ font-family: monospace;
+ margin: 0;
+ padding: 0;
+ font-size: 13px;
+ text-align: justify;
+ }
+ .container {
+ background: #0d001a;
+ border: 1px solid #ff99cc;
+ max-width: 768px;
+ margin: 40px auto;
+ padding: 16px;
+ }
+ h1 {
+ color: #ffccff;
+ margin-bottom: 8px;
+ font-size: 16px;
+ }
+ p {
+ color: #99ffcc;
+ line-height: 18px;
+ }
+ .verify-link {
+ display: inline-block;
+ background: #330066;
+ color: #ccffcc;
+ border: 1px solid #99ffcc;
+ padding: 10px 24px;
+ text-decoration: none;
+ font-weight: bold;
+ margin: 12px auto;
+ }
+ .verify-link:hover {
+ background: #ff99cc;
+ color: #1a001a;
+ border-color: #ff99cc;
+ }
+ .footer {
+ color: #ffccff;
+ margin-top: 32px;
+ font-size: 11px;
+ }
+ </style>
+ </head>
+ <body>
+ <div class="container">
+ <h1>Welcome to {{ .Appname }}, {{ .Username }}!</h1>
+ <p>
+ Your account has been created successfully. To complete your
+ registration, you need to verify your email address by clicking the link
+ below:
+ </p>
+ <a class="verify-link" href="{{ .Link }}">Verify Email</a>
+ <p>
+ If you are unable to click the link, copy and paste it into your
+ browser:
+ </p>
+ <p>
+ <a href="{{ .Link }}">{{ .Link }}</a>
+ </p>
+ <div class="footer">
+ If you did not register for this account, you can ignore this email.
+ </div>
+ </div>
+ </body>
+</html>
diff --git a/templates/register.django b/templates/register.django
index 02ba9d9..2990ba5 100644
--- a/templates/register.django
+++ b/templates/register.django
@@ -2,14 +2,17 @@
{% block content %}
<div class="centered-main">
- <div class="bordered-box">
+ <div class="bordered-box" style="max-width: 786px;">
<img src="/static/images/1c8fcc330ea1e971440cd3bdb8993a81.webp" alt="Register Image" class="q-img" />
<h1>Join {{ Appname }}</h1>
<p>Create your account to start sharing and exploring images!</p>
- {% if Error %}
- <div class="error">{{ Error }}</div>
- {% endif %}
<form action="/register" method="POST" class="ibform">
+ {% if Error %}
+ <div class="error">{{ Error|safe }}</div>
+ {% endif %}
+ {% if Success %}
+ <div class="success">{{ Success|safe }}</div>
+ {% endif %}
<div class="fgroup">
<div class="fg-main">
<label for="username">Username</label>
diff --git a/utils/auth/auth.go b/utils/auth/auth.go
new file mode 100644
index 0000000..7b8f260
--- /dev/null
+++ b/utils/auth/auth.go
@@ -0,0 +1,26 @@
+package auth
+
+import (
+ "imageboard/models"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func GetCurrentUser(ctx *fiber.Ctx) *models.User {
+ if user, ok := ctx.Locals("User").(*models.User); ok {
+ return user
+ }
+ return nil
+}
+
+func IsAuthenticated(ctx *fiber.Ctx) bool {
+ return GetCurrentUser(ctx) != nil
+}
+
+func GetRedirectURL(ctx *fiber.Ctx) string {
+ referer := ctx.Get("Referer")
+ if referer != "" && referer != ctx.BaseURL()+"/login" && referer != ctx.BaseURL()+"/register" {
+ return referer
+ }
+ return "/"
+}
diff --git a/utils/email/email.go b/utils/email/email.go
new file mode 100644
index 0000000..fb1d58d
--- /dev/null
+++ b/utils/email/email.go
@@ -0,0 +1,124 @@
+package email
+
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "imageboard/config"
+ "imageboard/database"
+ "imageboard/models"
+ "net/smtp"
+ "regexp"
+)
+
+func extractEmailAddress(from string) string {
+ re := regexp.MustCompile(`<([^>]+)>`)
+ matches := re.FindStringSubmatch(from)
+ if len(matches) == 2 {
+ return matches[1]
+ }
+ return from
+}
+
+func SendMail(to, subject, body string) error {
+ var auth smtp.Auth
+ if config.SMTP.Username != "" {
+ auth = smtp.PlainAuth("", config.SMTP.Username, config.SMTP.Password, config.SMTP.Host)
+ } else {
+ auth = nil
+ }
+ fromHeader := config.SMTP.From
+ fromAddress := extractEmailAddress(config.SMTP.From)
+ msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n%s",
+ fromHeader, to, subject, body)
+ addr := fmt.Sprintf("%s:%d", config.SMTP.Host, config.SMTP.Port)
+ return smtp.SendMail(addr, auth, fromAddress, []string{to}, []byte(msg))
+}
+
+func SendVerificationEmail(user *models.User) error {
+ token, err := database.GenerateEmailToken(int(user.ID), models.EmailTokenTypeVerification)
+ if err != nil {
+ return fmt.Errorf("failed to generate verification token: %w", err)
+ }
+
+ tmpl, err := template.ParseFiles("templates/email/verification.html")
+ if err != nil {
+ return fmt.Errorf("failed to parse email template: %w", err)
+ }
+ verificationLink := fmt.Sprintf("%s/account/verify?token=%s", config.Server.AppBaseURL, token.Token)
+ data := struct {
+ Username string
+ Appname string
+ Link string
+ }{
+ Username: user.Username,
+ Appname: config.Server.AppName,
+ Link: verificationLink,
+ }
+
+ var body bytes.Buffer
+ if err := tmpl.Execute(&body, data); err != nil {
+ return fmt.Errorf("failed to execute email template: %w", err)
+ }
+
+ subject := fmt.Sprintf("Verify your email for %s", config.Server.AppName)
+ return SendMail(user.Email, subject, body.String())
+}
+
+// func SendPasswordResetEmail(user *models.User) error {
+// token, err := user.GenerateToken(database.DB, models.EmailTokenTypePasswordReset)
+// if err != nil {
+// return fmt.Errorf("failed to generate password reset token: %w", err)
+// }
+
+// tmpl, err := template.ParseFiles("templates/email/password_reset.html")
+// if err != nil {
+// return fmt.Errorf("failed to parse email template: %w", err)
+// }
+// resetLink := fmt.Sprintf("%s/account/reset-password?token=%s", config.Server.AppBaseURL, token.Token)
+// data := struct {
+// Username string
+// Link string
+// }{
+// Username: user.Username,
+// Link: resetLink,
+// }
+
+// var body bytes.Buffer
+// if err := tmpl.Execute(&body, data); err != nil {
+// return fmt.Errorf("failed to execute email template: %w", err)
+// }
+
+// subject := fmt.Sprintf("Password reset for %s", config.Server.AppName)
+// return SendMail(user.Email, subject, body.String())
+// }
+
+// func SendEmailChangeConfirmation(user *models.User, newEmail string) error {
+// token, err := user.GenerateToken(database.DB, models.EmailTokenTypeChangeEmail)
+// if err != nil {
+// return fmt.Errorf("failed to generate email change token: %w", err)
+// }
+
+// tmpl, err := template.ParseFiles("templates/email/email_change.html")
+// if err != nil {
+// return fmt.Errorf("failed to parse email template: %w", err)
+// }
+// confirmLink := fmt.Sprintf("%s/account/confirm-email-change?token=%s&email=%s", config.Server.AppBaseURL, token.Token, newEmail)
+// data := struct {
+// Username string
+// NewEmail string
+// Link string
+// }{
+// Username: user.Username,
+// NewEmail: newEmail,
+// Link: confirmLink,
+// }
+
+// var body bytes.Buffer
+// if err := tmpl.Execute(&body, data); err != nil {
+// return fmt.Errorf("failed to execute email template: %w", err)
+// }
+
+// subject := fmt.Sprintf("Confirm email change for %s", config.Server.AppName)
+// return SendMail(newEmail, subject, body.String())
+// }
diff --git a/utils/validators/tokens.go b/utils/validators/tokens.go
new file mode 100644
index 0000000..f377c2e
--- /dev/null
+++ b/utils/validators/tokens.go
@@ -0,0 +1,14 @@
+package validators
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+)
+
+func GenerateRandomToken() (string, error) {
+ bytes := make([]byte, 32)
+ if _, err := rand.Read(bytes); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(bytes), nil
+}