1627 lines
43 KiB
Go
1627 lines
43 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"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 {
|
|
http.Redirect(w, r, "/login?flash=Sign in to manage your account", 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 {
|
|
http.Redirect(w, r, "/login?flash=Sign in to manage your account", 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)
|
|
|
|
http.Redirect(w, r, "/login?flash=Email updated. Check your inbox to confirm the new address. You will need to sign in again after verification.", http.StatusSeeOther)
|
|
}
|
|
|
|
func (h *PageHandler) UpdateUsername(w http.ResponseWriter, r *http.Request) {
|
|
user := h.currentUserWithLockCheck(w, r)
|
|
if user == nil {
|
|
http.Redirect(w, r, "/login?flash=Sign in to manage your account", 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
|
|
}
|
|
|
|
http.Redirect(w, r, "/settings?flash=Username updated successfully.", http.StatusSeeOther)
|
|
}
|
|
|
|
func (h *PageHandler) UpdatePassword(w http.ResponseWriter, r *http.Request) {
|
|
user := h.currentUserWithLockCheck(w, r)
|
|
if user == nil {
|
|
http.Redirect(w, r, "/login?flash=Sign in to manage your account", 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
|
|
}
|
|
|
|
http.Redirect(w, r, "/settings?flash=Password updated successfully.", http.StatusSeeOther)
|
|
}
|
|
|
|
func (h *PageHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
|
|
user := h.currentUserWithLockCheck(w, r)
|
|
if user == nil {
|
|
http.Redirect(w, r, "/login?flash=Sign in to manage your account", 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
|
|
}
|
|
|
|
http.Redirect(w, r, "/settings?flash=Check your inbox for a confirmation link to finish deleting your account.", 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 {
|
|
http.Redirect(w, r, "/login?flash=Please sign in to vote", 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)
|
|
}
|