Files
goyco/internal/handlers/page_handler.go

1638 lines
44 KiB
Go

package handlers
import (
"context"
"errors"
"fmt"
"html/template"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"goyco/internal/config"
"goyco/internal/database"
"goyco/internal/middleware"
"goyco/internal/repositories"
"goyco/internal/services"
"goyco/internal/validation"
"github.com/go-chi/chi/v5"
)
type PageHandler struct {
templatesDir string
authService AuthServiceInterface
postRepo repositories.PostRepository
voteService *services.VoteService
userRepo repositories.UserRepository
titleFetcher services.TitleFetcher
config *config.Config
postQueries *services.PostQueries
funcMap template.FuncMap
mu sync.RWMutex
templates map[string]*template.Template
}
type PageData struct {
Title string
SiteTitle string
User *database.User
Posts []database.Post
PostsSort string
PostsSortTopURL string
PostsSortNewURL string
CurrentPath string
Post *database.Post
Errors []string
Flash string
FormValues map[string]string
FormErrors map[string][]string
CurrentVote database.VoteType
UpVotes int
DownVotes int
CSRFToken string
CSPNonce string
Score int
ShowLoginLinks bool
VerificationSuccess bool
SearchQuery string
Token string
HasPosts bool
PostCount int64
}
func (d *PageData) setFormError(field, message string) {
if d.FormErrors == nil {
d.FormErrors = make(map[string][]string)
}
d.FormErrors[field] = []string{message}
}
func (h *PageHandler) newPageData(title string) *PageData {
return &PageData{
Title: title,
SiteTitle: h.config.App.Title,
}
}
func NewPageHandler(templatesDir string, authService AuthServiceInterface, postRepo repositories.PostRepository, voteService *services.VoteService, userRepo repositories.UserRepository, titleFetcher services.TitleFetcher, config *config.Config) (*PageHandler, error) {
if templatesDir == "" {
templatesDir = "internal/templates"
}
handler := &PageHandler{
templatesDir: templatesDir,
authService: authService,
postRepo: postRepo,
voteService: voteService,
userRepo: userRepo,
titleFetcher: titleFetcher,
config: config,
postQueries: services.NewPostQueries(postRepo, voteService),
funcMap: template.FuncMap{
"formatTime": func(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format("02 Jan 2006 15:04")
},
"truncate": func(s string, length int) string {
if len(s) <= length {
return s
}
if length <= 3 {
return s[:length]
}
return s[:length-3] + "..."
},
"substr": func(s string, start, length int) string {
if start >= len(s) {
return ""
}
end := start + length
if end > len(s) {
end = len(s)
}
return s[start:end]
},
"upper": strings.ToUpper,
},
templates: make(map[string]*template.Template),
}
if err := handler.reloadTemplates(); err != nil {
return nil, err
}
return handler, nil
}
func (h *PageHandler) Home(w http.ResponseWriter, r *http.Request) {
user := h.currentUser(r)
sortParam := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("sort")))
postsSort := "top"
ctx := services.VoteContext{
UserID: 0,
IPAddress: GetClientIP(r),
UserAgent: r.UserAgent(),
}
if user != nil {
ctx.UserID = user.ID
}
var (
posts []database.Post
err error
)
switch sortParam {
case "new", "newest", "latest":
postsSort = "new"
posts, err = h.postQueries.GetNewest(50, ctx)
default:
posts, err = h.postQueries.GetTop(50, ctx)
}
if err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to load posts")
return
}
currentPath := strings.TrimSpace(r.URL.RequestURI())
if currentPath == "" {
currentPath = "/"
}
token, err := h.generateCSRFToken(w, r)
if err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
csrfToken := token
data := h.newPageData(h.config.App.Title)
data.User = user
data.Posts = posts
data.PostsSort = postsSort
data.PostsSortTopURL = "/"
data.PostsSortNewURL = "/?sort=new"
data.CurrentPath = currentPath
data.SearchQuery = ""
data.CSRFToken = csrfToken
h.render(w, r, "home.gohtml", data)
}
func (h *PageHandler) Search(w http.ResponseWriter, r *http.Request) {
user := h.currentUser(r)
query := strings.TrimSpace(r.URL.Query().Get("q"))
ctx := services.VoteContext{
UserID: 0,
IPAddress: GetClientIP(r),
UserAgent: r.UserAgent(),
}
if user != nil {
ctx.UserID = user.ID
}
var posts []database.Post
var err error
if query != "" {
opts := services.QueryOptions{
Limit: 50,
Offset: 0,
}
posts, err = h.postQueries.GetSearch(query, opts, ctx)
if err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to search posts")
return
}
}
currentPath := strings.TrimSpace(r.URL.RequestURI())
if currentPath == "" {
currentPath = "/search"
}
token, err := h.generateCSRFToken(w, r)
if err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
csrfToken := token
data := h.newPageData("Search results")
data.User = user
data.Posts = posts
data.SearchQuery = query
data.CurrentPath = currentPath
data.CSRFToken = csrfToken
h.render(w, r, "search.gohtml", data)
}
func (h *PageHandler) ShowPost(w http.ResponseWriter, r *http.Request) {
user := h.currentUser(r)
postIDStr := chi.URLParam(r, "id")
postID, err := strconv.Atoi(postIDStr)
if err != nil || postID <= 0 {
h.renderError(w, r, http.StatusBadRequest, "Invalid post identifier")
return
}
ctx := services.VoteContext{
UserID: 0,
IPAddress: GetClientIP(r),
UserAgent: r.UserAgent(),
}
if user != nil {
ctx.UserID = user.ID
}
post, err := h.postQueries.GetByID(uint(postID), ctx)
if err != nil {
h.renderError(w, r, http.StatusNotFound, "Post not found")
return
}
data := h.newPageData(post.Title)
data.User = user
data.Post = post
data.UpVotes = post.UpVotes
data.DownVotes = post.DownVotes
data.Score = post.Score
if post.CurrentVote != "" {
data.CurrentVote = post.CurrentVote
}
token, err := h.generateCSRFToken(w, r)
if err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
data.CSRFToken = token
h.render(w, r, "post.gohtml", data)
}
func (h *PageHandler) NewPostForm(w http.ResponseWriter, r *http.Request) {
user := h.currentUser(r)
if user == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
data := h.newPageData("Share a link")
data.User = user
data.FormValues = map[string]string{}
token, err := h.generateCSRFToken(w, r)
if err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
data.CSRFToken = token
h.render(w, r, "new_post.gohtml", data)
}
func (h *PageHandler) CreatePost(w http.ResponseWriter, r *http.Request) {
user := h.currentUserWithLockCheck(w, r)
if user == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
h.renderError(w, r, http.StatusBadRequest, "Invalid form submission")
return
}
title := strings.TrimSpace(r.FormValue("title"))
url := strings.TrimSpace(r.FormValue("url"))
content := strings.TrimSpace(r.FormValue("content"))
var errorsList []string
if url == "" {
errorsList = append(errorsList, "URL is required")
}
if title == "" && url != "" && h.titleFetcher != nil {
titleCtx, cancel := context.WithTimeout(r.Context(), 7*time.Second)
defer cancel()
fetchedTitle, err := h.titleFetcher.FetchTitle(titleCtx, url)
if err != nil {
switch {
case errors.Is(err, services.ErrUnsupportedScheme):
errorsList = append(errorsList, "Only HTTP and HTTPS URLs are supported")
case errors.Is(err, services.ErrTitleNotFound):
errorsList = append(errorsList, "Title could not be extracted from the provided URL")
default:
errorsList = append(errorsList, "Failed to fetch title from URL")
}
} else {
title = fetchedTitle
}
}
if title == "" {
errorsList = append(errorsList, "Title is required")
}
if len(errorsList) > 0 {
token, err := h.generateCSRFToken(w, r)
if err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
data := &PageData{
Title: "Share a link",
User: user,
Errors: errorsList,
FormValues: map[string]string{
"title": title,
"url": url,
"content": content,
},
CSRFToken: token,
}
h.render(w, r, "new_post.gohtml", data)
return
}
post := &database.Post{
Title: title,
URL: url,
Content: content,
AuthorID: &user.ID,
AuthorName: user.Username,
}
if err := h.postRepo.Create(post); err != nil {
data := &PageData{
Title: "Share a link",
User: user,
Errors: []string{"Could not create the post. Please try again."},
FormValues: map[string]string{
"title": title,
"url": url,
"content": content,
},
}
h.render(w, r, "new_post.gohtml", data)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (h *PageHandler) LoginForm(w http.ResponseWriter, r *http.Request) {
if h.currentUser(r) != nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
flash := strings.TrimSpace(r.URL.Query().Get("flash"))
if flash == "" && r.URL.Query().Get("verified") != "" {
flash = "Account verified. You can now sign in."
}
if flash == "" && r.URL.Query().Get("reset") == "success" {
flash = "Password reset successfully. You can now sign in with your new password."
}
data := &PageData{
Title: "Sign in",
FormValues: map[string]string{},
Flash: flash,
}
if err := h.setCSRFToken(w, r, data); err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
h.render(w, r, "login.gohtml", data)
}
func (h *PageHandler) Login(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
h.renderError(w, r, http.StatusBadRequest, "Invalid form submission")
return
}
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
var errorsList []string
if username == "" {
errorsList = append(errorsList, "Username is required")
}
if strings.TrimSpace(password) == "" {
errorsList = append(errorsList, "Password is required")
}
if len(errorsList) > 0 {
data := &PageData{
Title: "Sign in",
Errors: errorsList,
FormValues: map[string]string{
"username": username,
},
}
if err := h.setCSRFToken(w, r, data); err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
h.render(w, r, "login.gohtml", data)
return
}
if h.authService == nil {
h.renderError(w, r, http.StatusInternalServerError, "Authentication service is not available")
return
}
result, err := h.authService.Login(username, password)
if err != nil {
message := strings.TrimSpace(err.Error())
if errors.Is(err, services.ErrInvalidCredentials) {
message = "Invalid username or password"
} else if errors.Is(err, services.ErrEmailNotVerified) {
message = "Please confirm your email before signing in"
} else if errors.Is(err, services.ErrAccountLocked) {
message = "Your account has been locked. Please contact us for assistance."
}
if message == "" {
message = "Unable to sign you in right now. Please try again."
}
data := &PageData{
Title: "Sign in",
Errors: []string{message},
FormValues: map[string]string{
"username": username,
},
}
if err := h.setCSRFToken(w, r, data); err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
h.render(w, r, "login.gohtml", data)
return
}
cookie := &http.Cookie{
Name: "auth_token",
Value: result.AccessToken,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: IsHTTPS(r),
Expires: time.Now().Add(24 * time.Hour),
}
http.SetCookie(w, cookie)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (h *PageHandler) RegisterForm(w http.ResponseWriter, r *http.Request) {
if h.currentUser(r) != nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
data := &PageData{
Title: "Create account",
FormValues: map[string]string{"email": ""},
}
if err := h.setCSRFToken(w, r, data); err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
h.render(w, r, "register.gohtml", data)
}
func (h *PageHandler) Register(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
h.renderError(w, r, http.StatusBadRequest, "Invalid form submission")
return
}
username := strings.TrimSpace(r.FormValue("username"))
email := strings.TrimSpace(r.FormValue("email"))
password := r.FormValue("password")
confirm := r.FormValue("password_confirm")
var errorsList []string
if username == "" {
errorsList = append(errorsList, "Username is required")
}
if email == "" {
errorsList = append(errorsList, "Email is required")
}
if strings.TrimSpace(password) == "" {
errorsList = append(errorsList, "Password is required")
}
if password != confirm {
errorsList = append(errorsList, "Passwords do not match")
}
if len(errorsList) > 0 {
data := &PageData{
Title: "Create account",
Errors: errorsList,
FormValues: map[string]string{
"username": username,
"email": email,
},
}
if err := h.setCSRFToken(w, r, data); err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
h.render(w, r, "register.gohtml", data)
return
}
if h.authService == nil {
h.renderError(w, r, http.StatusInternalServerError, "Authentication service is not available")
return
}
_, err := h.authService.Register(username, email, password)
if err != nil {
message := strings.TrimSpace(err.Error())
switch {
case errors.Is(err, services.ErrUsernameTaken):
message = "That username is already taken. Try another one."
case errors.Is(err, services.ErrEmailTaken):
message = "That email is already registered. Try signing in or use another email."
case message == "":
message = "Unable to create the account right now. Please try again."
}
data := &PageData{
Title: "Create account",
Errors: []string{message},
FormValues: map[string]string{
"username": username,
"email": email,
},
}
if err := h.setCSRFToken(w, r, data); err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
h.render(w, r, "register.gohtml", data)
return
}
data := &PageData{
Title: "Sign in",
Flash: "Account created. Check your inbox to confirm your email before signing in.",
FormValues: map[string]string{
"username": username,
},
}
h.render(w, r, "login.gohtml", data)
}
func (h *PageHandler) ConfirmEmailPage(w http.ResponseWriter, r *http.Request) {
token := strings.TrimSpace(r.URL.Query().Get("token"))
data := &PageData{
Title: "Confirm email",
}
if token == "" {
data.Errors = []string{"The verification link is missing or invalid."}
h.render(w, r, "confirm_email.gohtml", data)
return
}
if h.authService == nil {
h.renderError(w, r, http.StatusInternalServerError, "Email verification is not available right now")
return
}
if _, err := h.authService.ConfirmEmail(token); err != nil {
message := "We couldn't verify your account. The link may be invalid or expired."
if !errors.Is(err, services.ErrInvalidVerificationToken) {
message = "We couldn't verify your account right now. Please try again later."
}
data.Errors = []string{message}
} else {
data.VerificationSuccess = true
data.Flash = "Account verified. You can now sign in."
}
h.render(w, r, "confirm_email.gohtml", data)
}
func (h *PageHandler) ResendVerificationForm(w http.ResponseWriter, r *http.Request) {
data := &PageData{
Title: "Resend verification email",
}
if err := h.setCSRFToken(w, r, data); err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
h.render(w, r, "resend_verification.gohtml", data)
}
func (h *PageHandler) ResendVerification(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
h.renderError(w, r, http.StatusBadRequest, "Invalid form data")
return
}
email := strings.TrimSpace(r.FormValue("email"))
data := &PageData{
Title: "Resend verification email",
FormValues: map[string]string{
"email": email,
},
}
if !middleware.ValidateCSRFToken(r) {
data.Errors = []string{"Invalid security token. Please try again."}
if err := h.setCSRFToken(w, r, data); err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
h.render(w, r, "resend_verification.gohtml", data)
return
}
if email == "" {
data.Errors = []string{"Email address is required."}
if err := h.setCSRFToken(w, r, data); err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
h.render(w, r, "resend_verification.gohtml", data)
return
}
if h.authService == nil {
h.renderError(w, r, http.StatusInternalServerError, "Email verification is not available right now")
return
}
err := h.authService.ResendVerificationEmail(email)
if err != nil {
message := "Unable to resend verification email. Please try again later."
switch {
case errors.Is(err, services.ErrInvalidCredentials):
message = "No account found with this email address."
case errors.Is(err, services.ErrInvalidEmail):
message = "Please enter a valid email address."
case err.Error() == "email already verified":
message = "This email address is already verified. You can sign in now."
case err.Error() == "verification email sent recently, please wait before requesting another":
message = "Please wait 5 minutes before requesting another verification email."
}
data.Errors = []string{message}
if err := h.setCSRFToken(w, r, data); err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
h.render(w, r, "resend_verification.gohtml", data)
return
}
data.Flash = "Verification email sent! Check your inbox for the confirmation link."
if err := h.setCSRFToken(w, r, data); err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
h.render(w, r, "resend_verification.gohtml", data)
}
func (h *PageHandler) ForgotPasswordForm(w http.ResponseWriter, r *http.Request) {
data := &PageData{
Title: "Reset password",
}
if err := h.setCSRFToken(w, r, data); err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
h.render(w, r, "forgot_password.gohtml", data)
}
func (h *PageHandler) ForgotPassword(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
h.renderError(w, r, http.StatusBadRequest, "Invalid form data")
return
}
usernameOrEmail := strings.TrimSpace(r.FormValue("username_or_email"))
data := &PageData{
Title: "Reset password",
FormValues: map[string]string{
"username_or_email": usernameOrEmail,
},
}
if err := h.setCSRFToken(w, r, data); err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
if usernameOrEmail == "" {
data.Errors = []string{"Username or email address is required."}
h.render(w, r, "forgot_password.gohtml", data)
return
}
if h.authService == nil {
h.renderError(w, r, http.StatusInternalServerError, "Password reset is not available right now")
return
}
if err := h.authService.RequestPasswordReset(usernameOrEmail); err != nil {
message := "Unable to send password reset email. Please try again later."
if !strings.Contains(err.Error(), "email") {
message = "Unable to send password reset email. Please try again later."
}
data.Errors = []string{message}
h.render(w, r, "forgot_password.gohtml", data)
return
}
data.Flash = "If an account with that username or email exists, we've sent a password reset link."
h.render(w, r, "forgot_password.gohtml", data)
}
func (h *PageHandler) ResetPasswordForm(w http.ResponseWriter, r *http.Request) {
token := strings.TrimSpace(r.URL.Query().Get("token"))
data := &PageData{
Title: "Set new password",
Token: token,
}
if err := h.setCSRFToken(w, r, data); err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
if token == "" {
data.Errors = []string{"The reset link is missing or invalid."}
h.render(w, r, "reset_password.gohtml", data)
return
}
h.render(w, r, "reset_password.gohtml", data)
}
func (h *PageHandler) ResetPassword(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
h.renderError(w, r, http.StatusBadRequest, "Invalid form data")
return
}
token := strings.TrimSpace(r.FormValue("token"))
password := strings.TrimSpace(r.FormValue("password"))
confirmPassword := strings.TrimSpace(r.FormValue("confirm_password"))
data := &PageData{
Title: "Set new password",
Token: token,
FormValues: map[string]string{
"password": password,
},
}
if err := h.setCSRFToken(w, r, data); err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
if token == "" {
data.Errors = []string{"The reset link is missing or invalid."}
h.render(w, r, "reset_password.gohtml", data)
return
}
if password == "" {
data.Errors = []string{"Password is required."}
h.render(w, r, "reset_password.gohtml", data)
return
}
if err := validation.ValidatePassword(password); err != nil {
data.Errors = []string{err.Error()}
h.render(w, r, "reset_password.gohtml", data)
return
}
if password != confirmPassword {
data.Errors = []string{"Passwords do not match."}
h.render(w, r, "reset_password.gohtml", data)
return
}
if h.authService == nil {
h.renderError(w, r, http.StatusInternalServerError, "Password reset is not available right now")
return
}
if err := h.authService.ResetPassword(token, password); err != nil {
message := "Unable to reset password. The link may be invalid or expired."
if strings.Contains(err.Error(), "expired") {
message = "The reset link has expired. Please request a new one."
} else if strings.Contains(err.Error(), "invalid") {
message = "The reset link is invalid. Please request a new one."
}
data.Errors = []string{message}
h.render(w, r, "reset_password.gohtml", data)
return
}
http.Redirect(w, r, "/login?reset=success", http.StatusSeeOther)
}
func (h *PageHandler) Settings(w http.ResponseWriter, r *http.Request) {
user := h.currentUserWithLockCheck(w, r)
if user == nil {
redirectURL := "/login?flash=" + url.QueryEscape("Sign in to manage your account")
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
return
}
data := h.settingsPageData(user)
data.Flash = strings.TrimSpace(r.URL.Query().Get("flash"))
token, err := h.generateCSRFToken(w, r)
if err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
data.CSRFToken = token
h.render(w, r, "settings.gohtml", data)
}
func (h *PageHandler) UpdateEmail(w http.ResponseWriter, r *http.Request) {
user := h.currentUserWithLockCheck(w, r)
if user == nil {
redirectURL := "/login?flash=" + url.QueryEscape("Sign in to manage your account")
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
h.renderError(w, r, http.StatusBadRequest, "Invalid form submission")
return
}
newEmail := strings.TrimSpace(r.FormValue("email"))
data := h.settingsPageData(user)
if data.FormErrors == nil {
data.FormErrors = map[string][]string{}
}
data.FormValues["email"] = newEmail
if newEmail == "" {
token, err := h.generateCSRFToken(w, r)
if err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
data.CSRFToken = token
message := "Email is required"
data.Errors = []string{message}
data.setFormError("email", message)
h.render(w, r, "settings.gohtml", data)
return
}
if h.authService == nil {
h.renderError(w, r, http.StatusInternalServerError, "Authentication service is not available")
return
}
if _, err := h.authService.UpdateEmail(user.ID, newEmail); err != nil {
token, tokenErr := h.generateCSRFToken(w, r)
if tokenErr != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
data.CSRFToken = token
message := strings.TrimSpace(err.Error())
switch {
case errors.Is(err, services.ErrEmailTaken):
message = "That email is already in use. Choose another one."
case errors.Is(err, services.ErrEmailSenderUnavailable):
message = "We couldn't send the confirmation email. Try again later."
case message == "":
message = "We couldn't update your email right now."
}
data.Errors = []string{message}
data.setFormError("email", message)
h.render(w, r, "settings.gohtml", data)
return
}
if err := h.authService.InvalidateAllSessions(user.ID); err != nil {
}
h.clearAuthCookie(w, r)
redirectURL := "/login?flash=" + url.QueryEscape("Email updated. Check your inbox to confirm the new address. You will need to sign in again after verification.")
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
}
func (h *PageHandler) UpdateUsername(w http.ResponseWriter, r *http.Request) {
user := h.currentUserWithLockCheck(w, r)
if user == nil {
redirectURL := "/login?flash=" + url.QueryEscape("Sign in to manage your account")
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
h.renderError(w, r, http.StatusBadRequest, "Invalid form submission")
return
}
newUsername := strings.TrimSpace(r.FormValue("username"))
data := h.settingsPageData(user)
if data.FormErrors == nil {
data.FormErrors = map[string][]string{}
}
data.FormValues["username"] = newUsername
if newUsername == "" {
token, err := h.generateCSRFToken(w, r)
if err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
data.CSRFToken = token
message := "Username is required"
data.Errors = []string{message}
data.setFormError("username", message)
h.render(w, r, "settings.gohtml", data)
return
}
if h.authService == nil {
h.renderError(w, r, http.StatusInternalServerError, "Authentication service is not available")
return
}
if _, err := h.authService.UpdateUsername(user.ID, newUsername); err != nil {
token, tokenErr := h.generateCSRFToken(w, r)
if tokenErr != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
data.CSRFToken = token
message := strings.TrimSpace(err.Error())
switch {
case errors.Is(err, services.ErrUsernameTaken):
message = "That username is already taken. Try another one."
case message == "":
message = "We couldn't update your username right now."
}
data.Errors = []string{message}
h.render(w, r, "settings.gohtml", data)
return
}
redirectURL := "/settings?flash=" + url.QueryEscape("Username updated successfully.")
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
}
func (h *PageHandler) UpdatePassword(w http.ResponseWriter, r *http.Request) {
user := h.currentUserWithLockCheck(w, r)
if user == nil {
redirectURL := "/login?flash=" + url.QueryEscape("Sign in to manage your account")
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
h.renderError(w, r, http.StatusBadRequest, "Invalid form submission")
return
}
currentPassword := strings.TrimSpace(r.FormValue("current_password"))
newPassword := strings.TrimSpace(r.FormValue("new_password"))
confirmPassword := strings.TrimSpace(r.FormValue("confirm_password"))
data := h.settingsPageData(user)
if data.FormErrors == nil {
data.FormErrors = map[string][]string{}
}
data.FormValues["current_password"] = ""
data.FormValues["new_password"] = ""
data.FormValues["confirm_password"] = ""
if currentPassword == "" {
token, err := h.generateCSRFToken(w, r)
if err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
data.CSRFToken = token
message := "Current password is required"
data.Errors = []string{message}
data.setFormError("current_password", message)
h.render(w, r, "settings.gohtml", data)
return
}
if newPassword == "" {
token, err := h.generateCSRFToken(w, r)
if err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
data.CSRFToken = token
message := "New password is required"
data.Errors = []string{message}
data.setFormError("new_password", message)
h.render(w, r, "settings.gohtml", data)
return
}
if len(newPassword) < 8 {
token, err := h.generateCSRFToken(w, r)
if err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
data.CSRFToken = token
message := "New password must be at least 8 characters long"
data.Errors = []string{message}
data.setFormError("new_password", message)
h.render(w, r, "settings.gohtml", data)
return
}
if newPassword != confirmPassword {
token, err := h.generateCSRFToken(w, r)
if err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
data.CSRFToken = token
message := "New passwords do not match"
data.Errors = []string{message}
data.setFormError("confirm_password", message)
h.render(w, r, "settings.gohtml", data)
return
}
if h.authService == nil {
h.renderError(w, r, http.StatusInternalServerError, "Authentication service is not available")
return
}
if _, err := h.authService.UpdatePassword(user.ID, currentPassword, newPassword); err != nil {
token, tokenErr := h.generateCSRFToken(w, r)
if tokenErr != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
data.CSRFToken = token
message := strings.TrimSpace(err.Error())
switch {
case strings.Contains(message, "current password is incorrect"):
message = "Current password is incorrect"
case message == "":
message = "We couldn't update your password right now."
}
data.Errors = []string{message}
if strings.Contains(err.Error(), "current password") {
data.setFormError("current_password", message)
} else {
data.setFormError("new_password", message)
}
h.render(w, r, "settings.gohtml", data)
return
}
redirectURL := "/settings?flash=" + url.QueryEscape("Password updated successfully.")
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
}
func (h *PageHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
user := h.currentUserWithLockCheck(w, r)
if user == nil {
redirectURL := "/login?flash=" + url.QueryEscape("Sign in to manage your account")
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
h.renderError(w, r, http.StatusBadRequest, "Invalid form submission")
return
}
confirmation := strings.TrimSpace(r.FormValue("confirmation"))
data := h.settingsPageData(user)
if data.FormErrors == nil {
data.FormErrors = map[string][]string{}
}
if !strings.EqualFold(confirmation, "DELETE") {
token, err := h.generateCSRFToken(w, r)
if err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
data.CSRFToken = token
message := "Type DELETE in capital letters to confirm."
data.Errors = []string{message}
data.setFormError("delete", message)
h.render(w, r, "settings.gohtml", data)
return
}
if h.authService == nil {
h.renderError(w, r, http.StatusInternalServerError, "Authentication service is not available")
return
}
if err := h.authService.RequestAccountDeletion(user.ID); err != nil {
token, tokenErr := h.generateCSRFToken(w, r)
if tokenErr != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to generate security token")
return
}
data.CSRFToken = token
message := strings.TrimSpace(err.Error())
switch {
case errors.Is(err, services.ErrEmailSenderUnavailable):
message = "Account deletion isn't available right now because email delivery is disabled."
case errors.Is(err, services.ErrInvalidDeletionToken):
message = "We couldn't start the deletion process. Please try again."
case message == "":
message = "We couldn't start the deletion process right now."
}
data.Errors = []string{message}
data.setFormError("delete", message)
h.render(w, r, "settings.gohtml", data)
return
}
redirectURL := "/settings?flash=" + url.QueryEscape("Check your inbox for a confirmation link to finish deleting your account.")
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
}
func (h *PageHandler) ConfirmAccountDeletion(w http.ResponseWriter, r *http.Request) {
token := strings.TrimSpace(r.URL.Query().Get("token"))
if token == "" && r.Method == http.MethodPost {
if err := r.ParseForm(); err == nil {
token = strings.TrimSpace(r.FormValue("token"))
}
}
data := &PageData{
Title: "Confirm account deletion",
}
if token == "" {
data.Errors = []string{"The deletion link is missing or invalid."}
h.render(w, r, "confirm_delete.gohtml", data)
return
}
if h.authService == nil {
h.renderError(w, r, http.StatusInternalServerError, "Account deletion is not available right now")
return
}
if r.Method == http.MethodPost {
deletePostsStr := r.FormValue("delete_posts")
deletePosts := deletePostsStr == "true"
if err := h.authService.ConfirmAccountDeletionWithPosts(token, deletePosts); err != nil {
switch {
case errors.Is(err, services.ErrInvalidDeletionToken):
data.Errors = []string{"This deletion link is invalid or has expired."}
case errors.Is(err, services.ErrEmailSenderUnavailable):
data.Errors = []string{"Account deletion is currently unavailable because email delivery is disabled."}
case errors.Is(err, services.ErrDeletionEmailFailed):
h.clearAuthCookie(w, r)
data.Flash = "Your account has been deleted, but we couldn't send the confirmation email."
data.ShowLoginLinks = true
default:
data.Errors = []string{"We couldn't confirm the deletion right now. Please try again later."}
}
h.render(w, r, "confirm_delete.gohtml", data)
return
}
h.clearAuthCookie(w, r)
data.Flash = "Your account has been deleted."
data.ShowLoginLinks = true
h.render(w, r, "confirm_delete.gohtml", data)
return
}
hasPosts, postCount, err := h.validateDeletionTokenAndCheckPosts(token)
if err != nil {
switch {
case errors.Is(err, services.ErrInvalidDeletionToken):
data.Errors = []string{"This deletion link is invalid or has expired."}
default:
data.Errors = []string{"We couldn't validate the deletion link. Please try again later."}
}
h.render(w, r, "confirm_delete.gohtml", data)
return
}
data.Token = token
data.HasPosts = hasPosts
data.PostCount = postCount
h.render(w, r, "confirm_delete.gohtml", data)
}
func (h *PageHandler) validateDeletionTokenAndCheckPosts(token string) (bool, int64, error) {
if h.authService == nil {
return false, 0, fmt.Errorf("auth service not available")
}
userID, err := h.authService.GetUserIDFromDeletionToken(token)
if err != nil {
return false, 0, err
}
return h.authService.UserHasPosts(userID)
}
func (h *PageHandler) Logout(w http.ResponseWriter, r *http.Request) {
h.clearAuthCookie(w, r)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (h *PageHandler) settingsPageData(user *database.User) *PageData {
formValues := map[string]string{}
if user != nil {
formValues["email"] = user.Email
formValues["username"] = user.Username
}
return &PageData{
Title: "Account settings",
SiteTitle: h.config.App.Title,
User: user,
FormValues: formValues,
FormErrors: map[string][]string{},
}
}
func (h *PageHandler) clearAuthCookie(w http.ResponseWriter, r *http.Request) {
cookie := &http.Cookie{
Name: "auth_token",
Value: "",
Path: "/",
HttpOnly: true,
Secure: IsHTTPS(r),
Expires: time.Unix(0, 0),
MaxAge: -1,
SameSite: http.SameSiteLaxMode,
}
http.SetCookie(w, cookie)
}
func (h *PageHandler) Vote(w http.ResponseWriter, r *http.Request) {
user := h.currentUserWithLockCheck(w, r)
if user == nil {
redirectURL := "/login?flash=" + url.QueryEscape("Please sign in to vote")
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
h.renderError(w, r, http.StatusBadRequest, "Invalid vote submission")
return
}
postIDStr := chi.URLParam(r, "id")
postID, err := strconv.Atoi(postIDStr)
if err != nil || postID <= 0 {
h.renderError(w, r, http.StatusBadRequest, "Invalid post identifier")
return
}
action := strings.TrimSpace(r.FormValue("action"))
ipAddress := GetClientIP(r)
userAgent := r.UserAgent()
userID := user.ID
var voteType database.VoteType
switch action {
case "up":
voteType = database.VoteUp
case "down":
voteType = database.VoteDown
case "clear":
voteType = database.VoteNone
default:
h.renderError(w, r, http.StatusBadRequest, "Unsupported vote action")
return
}
serviceReq := services.VoteRequest{
UserID: userID,
PostID: uint(postID),
Type: voteType,
IPAddress: ipAddress,
UserAgent: userAgent,
}
_, err = h.voteService.CastVote(serviceReq)
if err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Unable to update the vote")
return
}
redirectTarget := ValidateRedirectURL(r.FormValue("redirect"))
if redirectTarget == "" {
redirectTarget = "/posts/" + strconv.Itoa(postID)
}
http.Redirect(w, r, redirectTarget, http.StatusSeeOther)
}
func (h *PageHandler) currentUser(r *http.Request) *database.User {
cookie, err := r.Cookie("auth_token")
if err != nil || strings.TrimSpace(cookie.Value) == "" {
return nil
}
if h.authService == nil {
return nil
}
userID, err := h.authService.VerifyToken(cookie.Value)
if err != nil || userID == 0 {
return nil
}
user, err := h.userRepo.GetByID(userID)
if err != nil {
return nil
}
user.Password = ""
return user
}
func (h *PageHandler) currentUserWithLockCheck(w http.ResponseWriter, r *http.Request) *database.User {
cookie, err := r.Cookie("auth_token")
if err != nil || strings.TrimSpace(cookie.Value) == "" {
return nil
}
if h.authService == nil {
return nil
}
userID, err := h.authService.VerifyToken(cookie.Value)
if err != nil || userID == 0 {
if errors.Is(err, services.ErrAccountLocked) {
h.clearAuthCookie(w, r)
}
return nil
}
user, err := h.userRepo.GetByID(userID)
if err != nil {
return nil
}
user.Password = ""
return user
}
func (h *PageHandler) generateCSRFToken(w http.ResponseWriter, r *http.Request) (string, error) {
token, err := middleware.CSRFToken()
if err != nil {
return "", err
}
middleware.SetCSRFToken(w, r, token)
return token, nil
}
func (h *PageHandler) setCSRFToken(w http.ResponseWriter, r *http.Request, data *PageData) error {
token, err := h.generateCSRFToken(w, r)
if err != nil {
return err
}
if data != nil {
data.CSRFToken = token
}
return nil
}
func (h *PageHandler) render(w http.ResponseWriter, r *http.Request, templateName string, data *PageData) {
tmpl, err := h.getTemplate(templateName)
if err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Template rendering error")
return
}
if data == nil {
data = &PageData{}
}
if data.FormValues == nil {
data.FormValues = map[string]string{}
}
if data.FormErrors == nil {
data.FormErrors = map[string][]string{}
}
data.CSPNonce = middleware.GetCSPNonceFromContext(r.Context())
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Template rendering error")
return
}
}
func (h *PageHandler) renderError(w http.ResponseWriter, r *http.Request, status int, message string) {
w.WriteHeader(status)
tmpl, err := h.getTemplate("error.gohtml")
if err != nil {
http.Error(w, message, status)
return
}
data := &PageData{
Title: http.StatusText(status),
Errors: []string{message},
CSPNonce: middleware.GetCSPNonceFromContext(r.Context()),
}
if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil {
http.Error(w, message, status)
}
}
func (h *PageHandler) reloadTemplates() error {
h.mu.Lock()
defer h.mu.Unlock()
layoutPath := filepath.Join(h.templatesDir, "base.gohtml")
if _, err := os.Stat(layoutPath); err != nil {
return err
}
partials, err := filepath.Glob(filepath.Join(h.templatesDir, "partials", "*.gohtml"))
if err != nil {
return err
}
pages, err := filepath.Glob(filepath.Join(h.templatesDir, "*.gohtml"))
if err != nil {
return err
}
templates := make(map[string]*template.Template)
for _, page := range pages {
if filepath.Base(page) == "base.gohtml" {
continue
}
files := append([]string{layoutPath}, partials...)
files = append(files, page)
tmpl, parseErr := template.New(filepath.Base(page)).Funcs(h.funcMap).ParseFiles(files...)
if parseErr != nil {
return parseErr
}
templates[filepath.Base(page)] = tmpl
}
if len(templates) == 0 {
return errors.New("no templates were loaded")
}
h.templates = templates
return nil
}
func (h *PageHandler) getTemplate(name string) (*template.Template, error) {
h.mu.RLock()
tmpl, ok := h.templates[name]
h.mu.RUnlock()
if ok {
return tmpl, nil
}
if err := h.reloadTemplates(); err != nil {
return nil, err
}
h.mu.RLock()
defer h.mu.RUnlock()
tmpl, ok = h.templates[name]
if !ok {
return nil, errors.New("template not found: " + name)
}
return tmpl, nil
}
func HSTSMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if IsHTTPS(r) {
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}
func (h *PageHandler) MountRoutes(r chi.Router, config RouteModuleConfig) {
public := r
if config.GeneralRateLimit != nil {
public = config.GeneralRateLimit(r)
}
public.Get("/", h.Home)
public.Get("/search", h.Search)
public.Get("/login", h.LoginForm)
public.Get("/register", h.RegisterForm)
public.Get("/confirm", h.ConfirmEmailPage)
public.Get("/resend-verification", h.ResendVerificationForm)
public.Get("/forgot-password", h.ForgotPasswordForm)
public.Get("/reset-password", h.ResetPasswordForm)
public.Get("/settings/delete/confirm", h.ConfirmAccountDeletion)
public.Get("/posts/new", h.NewPostForm)
public.Get("/posts/{id:[0-9]+}", h.ShowPost)
protected := r
if config.CSRFMiddleware != nil {
protected = protected.With(config.CSRFMiddleware)
}
if config.AuthRateLimit != nil {
protected = config.AuthRateLimit(protected)
}
protected.Post("/login", h.Login)
protected.Post("/logout", h.Logout)
protected.Post("/register", h.Register)
protected.Post("/resend-verification", h.ResendVerification)
protected.Post("/forgot-password", h.ForgotPassword)
protected.Post("/reset-password", h.ResetPassword)
protected.Get("/settings", h.Settings)
protected.Post("/settings/email", h.UpdateEmail)
protected.Post("/settings/username", h.UpdateUsername)
protected.Post("/settings/password", h.UpdatePassword)
protected.Post("/settings/delete", h.DeleteAccount)
protected.Post("/settings/delete/confirm", h.ConfirmAccountDeletion)
protected.Post("/posts", h.CreatePost)
protected.Post("/posts/{id:[0-9]+}/vote", h.Vote)
}