Compare commits

..

No commits in common. "f4bc5c925cdfc0f9dc96062558eb866e635df6b4" and "8ff0b7f2c25d82069f5185c21d6b47f8c8010010" have entirely different histories.

37 changed files with 142 additions and 534 deletions

View File

@ -29,7 +29,6 @@ func AboutHandler(app *App) http.HandlerFunc {
Title: "ThreadR - About", Title: "ThreadR - About",
Navbar: "about", Navbar: "about",
LoggedIn: loggedIn, LoggedIn: loggedIn,
AllowSignup: app.allowSignup(),
ShowCookieBanner: cookie == nil || cookie.Value != "accepted", ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir, BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static", StaticPath: app.Config.ThreadrDir + "/static",

View File

@ -1,119 +0,0 @@
package handlers
import (
"log"
"net/http"
"strconv"
"threadr/models"
"github.com/gorilla/sessions"
)
func AdminHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
userID, ok := session.Values["user_id"].(int)
if !ok {
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
return
}
user, err := models.GetUserByID(app.DB, userID)
if err != nil {
log.Printf("Error fetching user in AdminHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if user == nil || !models.HasGlobalPermission(user, models.PermManageUsers) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
cookie, _ := r.Cookie("threadr_cookie_banner")
if r.Method == http.MethodPost {
action := r.URL.Query().Get("action")
if action == "delete_user" {
targetIDStr := r.FormValue("user_id")
targetID, err := strconv.Atoi(targetIDStr)
if err != nil || targetID <= 0 {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
if targetID == userID {
http.Error(w, "Cannot delete your own account", http.StatusBadRequest)
return
}
if err := models.DeleteUser(app.DB, targetID); err != nil {
log.Printf("Error deleting user %d: %v", targetID, err)
http.Error(w, "Failed to delete user", http.StatusInternalServerError)
return
}
http.Redirect(w, r, app.Config.ThreadrDir+"/admin/?deleted=true", http.StatusFound)
return
}
allowSignup := r.FormValue("allow_signup") == "on"
if err := models.SetAllowSignup(app.DB, allowSignup); err != nil {
log.Printf("Error updating site settings in AdminHandler: %v", err)
http.Error(w, "Failed to save settings", http.StatusInternalServerError)
return
}
http.Redirect(w, r, app.Config.ThreadrDir+"/admin/?saved=true", http.StatusFound)
return
}
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
settings, err := models.GetSiteSettings(app.DB)
if err != nil {
log.Printf("Error fetching site settings in AdminHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
users, err := models.GetAllUsers(app.DB)
if err != nil {
log.Printf("Error fetching users in AdminHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
data := struct {
PageData
AllowSignup bool
ShowSuccess bool
ShowDeleted bool
Users []models.User
CurrentUserID int
}{
PageData: PageData{
Title: "ThreadR - Admin",
Navbar: "admin",
LoggedIn: true,
IsAdmin: true,
AllowSignup: settings.AllowSignup,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(),
},
AllowSignup: settings.AllowSignup,
ShowSuccess: r.URL.Query().Get("saved") == "true",
ShowDeleted: r.URL.Query().Get("deleted") == "true",
Users: users,
CurrentUserID: userID,
}
if err := app.Tmpl.ExecuteTemplate(w, "admin", data); err != nil {
log.Printf("Error executing template in AdminHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
}

View File

@ -14,14 +14,13 @@ type PageData struct {
Title string Title string
Navbar string Navbar string
LoggedIn bool LoggedIn bool
IsAdmin bool
AllowSignup bool
ShowCookieBanner bool ShowCookieBanner bool
BasePath string BasePath string
StaticPath string StaticPath string
CurrentURL string CurrentURL string
ContentTemplate string ContentTemplate string
BodyClass string BodyClass string
CSRFToken string
} }
type Config struct { type Config struct {

View File

@ -58,6 +58,11 @@ func BoardHandler(app *App) http.HandlerFunc {
if r.Method == http.MethodPost && loggedIn { if r.Method == http.MethodPost && loggedIn {
action := r.URL.Query().Get("action") action := r.URL.Query().Get("action")
if action == "create_thread" { if action == "create_thread" {
if !app.validateCSRFToken(r, session) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
title := r.FormValue("title") title := r.FormValue("title")
if title == "" { if title == "" {
http.Error(w, "Thread title is required", http.StatusBadRequest) http.Error(w, "Thread title is required", http.StatusBadRequest)
@ -114,11 +119,11 @@ func BoardHandler(app *App) http.HandlerFunc {
Title: "ThreadR - " + board.Name, Title: "ThreadR - " + board.Name,
Navbar: "boards", Navbar: "boards",
LoggedIn: loggedIn, LoggedIn: loggedIn,
AllowSignup: app.allowSignup(),
ShowCookieBanner: cookie == nil || cookie.Value != "accepted", ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir, BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static", StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(), CurrentURL: r.URL.RequestURI(),
CSRFToken: app.csrfToken(session),
}, },
Board: *board, Board: *board,
Threads: threads, Threads: threads,

View File

@ -26,6 +26,11 @@ func BoardsHandler(app *App) http.HandlerFunc {
} }
if r.Method == http.MethodPost && loggedIn && isAdmin { if r.Method == http.MethodPost && loggedIn && isAdmin {
if !app.validateCSRFToken(r, session) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
name := r.FormValue("name") name := r.FormValue("name")
description := r.FormValue("description") description := r.FormValue("description")
boardType := r.FormValue("type") boardType := r.FormValue("type")
@ -102,11 +107,11 @@ func BoardsHandler(app *App) http.HandlerFunc {
Title: "ThreadR - Boards", Title: "ThreadR - Boards",
Navbar: "boards", Navbar: "boards",
LoggedIn: loggedIn, LoggedIn: loggedIn,
AllowSignup: app.allowSignup(),
ShowCookieBanner: cookie == nil || cookie.Value != "accepted", ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir, BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static", StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(), CurrentURL: r.URL.RequestURI(),
CSRFToken: app.csrfToken(session),
}, },
PublicBoards: publicBoards, PublicBoards: publicBoards,
PrivateBoards: privateBoards, PrivateBoards: privateBoards,

View File

@ -147,6 +147,11 @@ func ChatHandler(app *App) http.HandlerFunc {
currentUsername := currentUser.Username currentUsername := currentUser.Username
if r.URL.Query().Get("ws") == "true" { if r.URL.Query().Get("ws") == "true" {
if !app.validateCSRFToken(r, session) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
ws, err := upgrader.Upgrade(w, r, nil) ws, err := upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
log.Printf("Error upgrading to WebSocket: %v", err) log.Printf("Error upgrading to WebSocket: %v", err)
@ -234,13 +239,13 @@ func ChatHandler(app *App) http.HandlerFunc {
Title: "ThreadR Chat - " + board.Name, Title: "ThreadR Chat - " + board.Name,
Navbar: "boards", Navbar: "boards",
LoggedIn: true, LoggedIn: true,
AllowSignup: app.allowSignup(),
ShowCookieBanner: cookie == nil || cookie.Value != "accepted", ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir, BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static", StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(), CurrentURL: r.URL.RequestURI(),
ContentTemplate: "chat-content", ContentTemplate: "chat-content",
BodyClass: "chat-page", BodyClass: "chat-page",
CSRFToken: app.csrfToken(session),
}, },
Board: *board, Board: *board,
Messages: messages, Messages: messages,

55
handlers/csrf.go Normal file
View File

@ -0,0 +1,55 @@
package handlers
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"net/http"
"github.com/gorilla/sessions"
)
const csrfSessionKey = "csrf_token"
func (app *App) ensureCSRFToken(session *sessions.Session) (string, error) {
if token, ok := session.Values[csrfSessionKey].(string); ok && token != "" {
return token, nil
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
token := base64.RawURLEncoding.EncodeToString(raw)
session.Values[csrfSessionKey] = token
return token, nil
}
func (app *App) csrfToken(session *sessions.Session) string {
token, err := app.ensureCSRFToken(session)
if err != nil {
return ""
}
return token
}
func (app *App) validateCSRFToken(r *http.Request, session *sessions.Session) bool {
expected, ok := session.Values[csrfSessionKey].(string)
if !ok || expected == "" {
return false
}
provided := r.Header.Get("X-CSRF-Token")
if provided == "" {
provided = r.FormValue("csrf_token")
}
if provided == "" {
provided = r.URL.Query().Get("csrf_token")
}
if len(provided) != len(expected) {
return false
}
return subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1
}

View File

@ -19,7 +19,6 @@ func HomeHandler(app *App) http.HandlerFunc {
Title: "ThreadR - Home", Title: "ThreadR - Home",
Navbar: "home", Navbar: "home",
LoggedIn: loggedIn, LoggedIn: loggedIn,
AllowSignup: app.allowSignup(),
ShowCookieBanner: cookie == nil || cookie.Value != "accepted", ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir, BasePath: app.Config.ThreadrDir,
StaticPath: filepath.Join(app.Config.ThreadrDir, "static"), StaticPath: filepath.Join(app.Config.ThreadrDir, "static"),

View File

@ -23,6 +23,11 @@ func LikeHandler(app *App) http.HandlerFunc {
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
if !app.validateCSRFToken(r, session) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
postIDStr := r.FormValue("post_id") postIDStr := r.FormValue("post_id")
postID, err := strconv.Atoi(postIDStr) postID, err := strconv.Atoi(postIDStr)
if err != nil { if err != nil {

View File

@ -13,6 +13,11 @@ func LoginHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session) session := r.Context().Value("session").(*sessions.Session)
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
if !app.validateCSRFToken(r, session) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
username := r.FormValue("username") username := r.FormValue("username")
password := r.FormValue("password") password := r.FormValue("password")
user, err := models.GetUserByUsername(app.DB, username) user, err := models.GetUserByUsername(app.DB, username)
@ -47,20 +52,18 @@ func LoginHandler(app *App) http.HandlerFunc {
Error string Error string
}{ }{
PageData: PageData{ PageData: PageData{
Title: "ThreadR - Login", Title: "ThreadR - Login",
Navbar: "login", Navbar: "login",
LoggedIn: false, LoggedIn: false,
AllowSignup: app.allowSignup(), BasePath: app.Config.ThreadrDir,
BasePath: app.Config.ThreadrDir, StaticPath: app.Config.ThreadrDir + "/static",
StaticPath: app.Config.ThreadrDir + "/static", CurrentURL: r.URL.RequestURI(),
CurrentURL: r.URL.RequestURI(), CSRFToken: app.csrfToken(session),
}, },
Error: "", Error: "",
} }
if r.URL.Query().Get("error") == "invalid" { if r.URL.Query().Get("error") == "invalid" {
data.Error = "Invalid username or password" data.Error = "Invalid username or password"
} else if r.URL.Query().Get("error") == "signup_disabled" {
data.Error = "Sign up is currently disabled by the administrator"
} }
if err := app.Tmpl.ExecuteTemplate(w, "login", data); err != nil { if err := app.Tmpl.ExecuteTemplate(w, "login", data); err != nil {

View File

@ -26,6 +26,11 @@ func NewsHandler(app *App) http.HandlerFunc {
} }
if r.Method == http.MethodPost && loggedIn && isAdmin { if r.Method == http.MethodPost && loggedIn && isAdmin {
if !app.validateCSRFToken(r, session) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
if action := r.URL.Query().Get("action"); action == "delete" { if action := r.URL.Query().Get("action"); action == "delete" {
newsIDStr := r.URL.Query().Get("id") newsIDStr := r.URL.Query().Get("id")
newsID, err := strconv.Atoi(newsIDStr) newsID, err := strconv.Atoi(newsIDStr)
@ -81,11 +86,11 @@ func NewsHandler(app *App) http.HandlerFunc {
Title: "ThreadR - News", Title: "ThreadR - News",
Navbar: "news", Navbar: "news",
LoggedIn: loggedIn, LoggedIn: loggedIn,
AllowSignup: app.allowSignup(),
ShowCookieBanner: cookie == nil || cookie.Value != "accepted", ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir, BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static", StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(), CurrentURL: r.URL.RequestURI(),
CSRFToken: app.csrfToken(session),
}, },
News: newsItems, News: newsItems,
IsAdmin: isAdmin, IsAdmin: isAdmin,

View File

@ -1,96 +0,0 @@
package handlers
import (
"log"
"net/http"
"threadr/models"
"github.com/gorilla/sessions"
)
func PasswordHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
userID, ok := session.Values["user_id"].(int)
if !ok {
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
return
}
user, err := models.GetUserByID(app.DB, userID)
if err != nil {
log.Printf("Error fetching user in PasswordHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if user == nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
cookie, _ := r.Cookie("threadr_cookie_banner")
if r.Method == http.MethodPost {
currentPassword := r.FormValue("current_password")
newPassword := r.FormValue("new_password")
confirmPassword := r.FormValue("confirm_password")
if currentPassword == "" || newPassword == "" || confirmPassword == "" {
renderPasswordPage(app, w, r, session, cookie, "All fields are required.", false)
return
}
if !models.CheckPassword(currentPassword, user.AuthenticationSalt, user.AuthenticationAlgorithm, user.AuthenticationString) {
renderPasswordPage(app, w, r, session, cookie, "Current password is incorrect.", false)
return
}
if newPassword != confirmPassword {
renderPasswordPage(app, w, r, session, cookie, "New passwords do not match.", false)
return
}
if len(newPassword) < 8 {
renderPasswordPage(app, w, r, session, cookie, "New password must be at least 8 characters.", false)
return
}
if err := models.UpdateUserPassword(app.DB, userID, newPassword); err != nil {
log.Printf("Error updating password: %v", err)
renderPasswordPage(app, w, r, session, cookie, "Failed to update password. Please try again.", false)
return
}
http.Redirect(w, r, app.Config.ThreadrDir+"/password/?saved=true", http.StatusFound)
return
}
showSuccess := r.URL.Query().Get("saved") == "true"
renderPasswordPage(app, w, r, session, cookie, "", showSuccess)
}
}
func renderPasswordPage(app *App, w http.ResponseWriter, r *http.Request, session *sessions.Session, cookie *http.Cookie, errMsg string, showSuccess bool) {
data := struct {
PageData
Error string
ShowSuccess bool
}{
PageData: PageData{
Title: "ThreadR - Change Password",
Navbar: "password",
LoggedIn: true,
AllowSignup: app.allowSignup(),
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(),
},
Error: errMsg,
ShowSuccess: showSuccess,
}
if err := app.Tmpl.ExecuteTemplate(w, "password", data); err != nil {
log.Printf("Error executing template in PasswordHandler: %v", err)
}
}

View File

@ -19,6 +19,11 @@ func PreferencesHandler(app *App) http.HandlerFunc {
// Handle POST request (saving preferences) // Handle POST request (saving preferences)
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
if !app.validateCSRFToken(r, session) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
// Get form values // Get form values
autoSaveDrafts := r.FormValue("auto_save_drafts") == "on" autoSaveDrafts := r.FormValue("auto_save_drafts") == "on"
@ -65,12 +70,12 @@ func PreferencesHandler(app *App) http.HandlerFunc {
Title: "ThreadR - Preferences", Title: "ThreadR - Preferences",
Navbar: "preferences", Navbar: "preferences",
LoggedIn: true, LoggedIn: true,
AllowSignup: app.allowSignup(),
ShowCookieBanner: false, ShowCookieBanner: false,
BasePath: app.Config.ThreadrDir, BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static", StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(), CurrentURL: r.URL.RequestURI(),
ContentTemplate: "preferences-content", ContentTemplate: "preferences-content",
CSRFToken: app.csrfToken(session),
}, },
Preferences: prefs, Preferences: prefs,
ShowSuccess: showSuccess, ShowSuccess: showSuccess,

View File

@ -38,7 +38,6 @@ func ProfileHandler(app *App) http.HandlerFunc {
Title: "ThreadR - Profile", Title: "ThreadR - Profile",
Navbar: "profile", Navbar: "profile",
LoggedIn: true, LoggedIn: true,
AllowSignup: app.allowSignup(),
ShowCookieBanner: false, ShowCookieBanner: false,
BasePath: app.Config.ThreadrDir, BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static", StaticPath: app.Config.ThreadrDir + "/static",

View File

@ -34,6 +34,11 @@ func ProfileEditHandler(app *App) http.HandlerFunc {
} }
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
if !app.validateCSRFToken(r, session) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxProfileImageBytes+(256<<10)) r.Body = http.MaxBytesReader(w, r.Body, maxProfileImageBytes+(256<<10))
// Handle file upload // Handle file upload
@ -128,11 +133,11 @@ func ProfileEditHandler(app *App) http.HandlerFunc {
Title: "ThreadR - Edit Profile", Title: "ThreadR - Edit Profile",
Navbar: "profile", Navbar: "profile",
LoggedIn: true, LoggedIn: true,
AllowSignup: app.allowSignup(),
ShowCookieBanner: false, ShowCookieBanner: false,
BasePath: app.Config.ThreadrDir, BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static", StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(), CurrentURL: r.URL.RequestURI(),
CSRFToken: app.csrfToken(session),
}, },
User: *user, User: *user,
} }

View File

@ -11,19 +11,12 @@ func SignupHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session) session := r.Context().Value("session").(*sessions.Session)
cookie, _ := r.Cookie("threadr_cookie_banner") cookie, _ := r.Cookie("threadr_cookie_banner")
settings, err := models.GetSiteSettings(app.DB)
if err != nil {
log.Printf("Error fetching site settings in SignupHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if !settings.AllowSignup {
http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=signup_disabled", http.StatusFound)
return
}
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
if !app.validateCSRFToken(r, session) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
username := r.FormValue("username") username := r.FormValue("username")
password := r.FormValue("password") password := r.FormValue("password")
passwordConfirm := r.FormValue("password_confirm") passwordConfirm := r.FormValue("password_confirm")
@ -39,12 +32,11 @@ func SignupHandler(app *App) http.HandlerFunc {
Title: "ThreadR - Sign Up", Title: "ThreadR - Sign Up",
Navbar: "signup", Navbar: "signup",
LoggedIn: false, LoggedIn: false,
AllowSignup: true,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted", ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir, BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static", StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(), CurrentURL: r.URL.RequestURI(),
CSRFToken: app.csrfToken(session),
}, },
Error: "Passwords do not match. Please try again.", Error: "Passwords do not match. Please try again.",
} }
@ -67,12 +59,11 @@ func SignupHandler(app *App) http.HandlerFunc {
Title: "ThreadR - Sign Up", Title: "ThreadR - Sign Up",
Navbar: "signup", Navbar: "signup",
LoggedIn: false, LoggedIn: false,
AllowSignup: true,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted", ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir, BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static", StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(), CurrentURL: r.URL.RequestURI(),
CSRFToken: app.csrfToken(session),
}, },
Error: "An error occurred during sign up. Please try again.", Error: "An error occurred during sign up. Please try again.",
} }
@ -94,12 +85,11 @@ func SignupHandler(app *App) http.HandlerFunc {
Title: "ThreadR - Sign Up", Title: "ThreadR - Sign Up",
Navbar: "signup", Navbar: "signup",
LoggedIn: session.Values["user_id"] != nil, LoggedIn: session.Values["user_id"] != nil,
AllowSignup: true,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted", ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir, BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static", StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(), CurrentURL: r.URL.RequestURI(),
CSRFToken: app.csrfToken(session),
}, },
Error: "", Error: "",
} }

View File

@ -1,16 +0,0 @@
package handlers
import (
"log"
"threadr/models"
)
func (app *App) allowSignup() bool {
settings, err := models.GetSiteSettings(app.DB)
if err != nil {
log.Printf("Error fetching site settings: %v", err)
return true
}
return settings.AllowSignup
}

View File

@ -59,6 +59,11 @@ func ThreadHandler(app *App) http.HandlerFunc {
if r.Method == http.MethodPost && loggedIn { if r.Method == http.MethodPost && loggedIn {
action := r.URL.Query().Get("action") action := r.URL.Query().Get("action")
if action == "submit" { if action == "submit" {
if !app.validateCSRFToken(r, session) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
content := r.FormValue("content") content := r.FormValue("content")
replyToStr := r.FormValue("reply_to") replyToStr := r.FormValue("reply_to")
if replyToStr == "" { if replyToStr == "" {
@ -160,11 +165,11 @@ func ThreadHandler(app *App) http.HandlerFunc {
Title: "ThreadR - " + thread.Title, Title: "ThreadR - " + thread.Title,
Navbar: "boards", Navbar: "boards",
LoggedIn: loggedIn, LoggedIn: loggedIn,
AllowSignup: app.allowSignup(),
ShowCookieBanner: cookie == nil || cookie.Value != "accepted", ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir, BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static", StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(), CurrentURL: r.URL.RequestURI(),
CSRFToken: app.csrfToken(session),
}, },
Thread: *thread, Thread: *thread,
Board: *board, Board: *board,

View File

@ -25,9 +25,6 @@ func UserHomeHandler(app *App) http.HandlerFunc {
http.Error(w, "User not found", http.StatusNotFound) http.Error(w, "User not found", http.StatusNotFound)
return return
} }
isAdmin := models.HasGlobalPermission(user, models.PermManageUsers)
data := struct { data := struct {
PageData PageData
Username string Username string
@ -36,8 +33,6 @@ func UserHomeHandler(app *App) http.HandlerFunc {
Title: "ThreadR - User Home", Title: "ThreadR - User Home",
Navbar: "userhome", Navbar: "userhome",
LoggedIn: true, LoggedIn: true,
IsAdmin: isAdmin,
AllowSignup: app.allowSignup(),
ShowCookieBanner: false, ShowCookieBanner: false,
BasePath: app.Config.ThreadrDir, BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static", StaticPath: app.Config.ThreadrDir + "/static",

14
main.go
View File

@ -231,12 +231,6 @@ func createTablesIfNotExist(db *sql.DB) error {
return fmt.Errorf("error creating user_preferences table: %v", err) return fmt.Errorf("error creating user_preferences table: %v", err)
} }
// Create site_settings table
err = models.EnsureSiteSettings(db)
if err != nil {
return fmt.Errorf("error ensuring site_settings table: %v", err)
}
log.Println("Database tables created.") log.Println("Database tables created.")
return nil return nil
} }
@ -357,11 +351,6 @@ func main() {
// Normal startup (without automatic table creation) // Normal startup (without automatic table creation)
log.Println("Starting ThreadR server...") log.Println("Starting ThreadR server...")
err = models.EnsureSiteSettings(db)
if err != nil {
log.Fatal("Error ensuring site_settings table:", err)
}
dir, err := os.Getwd() dir, err := os.Getwd()
if err != nil { if err != nil {
log.Fatal("Error getting working directory:", err) log.Fatal("Error getting working directory:", err)
@ -375,7 +364,6 @@ func main() {
// Parse page-specific templates with unique names // Parse page-specific templates with unique names
pageTemplates := []string{ pageTemplates := []string{
"admin.html",
"about.html", "about.html",
"board.html", "board.html",
"boards.html", "boards.html",
@ -439,9 +427,7 @@ func main() {
handle("/about/", handlers.AboutHandler(app)) handle("/about/", handlers.AboutHandler(app))
handleAuthed("/profile/", handlers.ProfileHandler(app)) handleAuthed("/profile/", handlers.ProfileHandler(app))
handleAuthed("/profile/edit/", handlers.ProfileEditHandler(app)) handleAuthed("/profile/edit/", handlers.ProfileEditHandler(app))
handleAuthed("/password/", handlers.PasswordHandler(app))
handleAuthed("/preferences/", handlers.PreferencesHandler(app)) handleAuthed("/preferences/", handlers.PreferencesHandler(app))
handleAuthed("/admin/", handlers.AdminHandler(app))
handleAuthed("/like/", handlers.LikeHandler(app)) handleAuthed("/like/", handlers.LikeHandler(app))
handle("/news/", handlers.NewsHandler(app)) handle("/news/", handlers.NewsHandler(app))
handle("/signup/", handlers.SignupHandler(app)) handle("/signup/", handlers.SignupHandler(app))

View File

@ -1,43 +0,0 @@
package models
import "database/sql"
const siteSettingsID = 1
type SiteSettings struct {
AllowSignup bool
}
func EnsureSiteSettings(db *sql.DB) error {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS site_settings (
id INT PRIMARY KEY,
allow_signup BOOLEAN NOT NULL DEFAULT TRUE,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)`)
if err != nil {
return err
}
_, err = db.Exec(`
INSERT INTO site_settings (id, allow_signup)
VALUES (?, TRUE)
ON DUPLICATE KEY UPDATE id = id
`, siteSettingsID)
return err
}
func GetSiteSettings(db *sql.DB) (*SiteSettings, error) {
settings := &SiteSettings{}
err := db.QueryRow("SELECT allow_signup FROM site_settings WHERE id = ?", siteSettingsID).Scan(&settings.AllowSignup)
if err != nil {
return nil, err
}
return settings, nil
}
func SetAllowSignup(db *sql.DB, allowSignup bool) error {
_, err := db.Exec("UPDATE site_settings SET allow_signup = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", allowSignup, siteSettingsID)
return err
}

View File

@ -168,68 +168,6 @@ func UpdateUserPfp(db *sql.DB, userID int, pfpFileID int64) error {
return err return err
} }
func UpdateUserPassword(db *sql.DB, userID int, newPassword string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcryptCost)
if err != nil {
return err
}
var saltBytes [16]byte
if _, err := rand.Read(saltBytes[:]); err != nil {
return fmt.Errorf("failed to generate salt: %w", err)
}
salt := fmt.Sprintf("%x", saltBytes)
_, err = db.Exec("UPDATE users SET authentication_string = ?, authentication_salt = ?, authentication_algorithm = 'bcrypt', updated_at = NOW() WHERE id = ?",
string(hash), salt, userID)
return err
}
func GetAllUsers(db *sql.DB) ([]User, error) {
query := "SELECT id, username, display_name, pfp_file_id, bio, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions FROM users ORDER BY created_at DESC"
rows, err := db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var users []User
for rows.Next() {
user := User{}
var displayName sql.NullString
var bio sql.NullString
var createdAtString sql.NullString
var updatedAtString sql.NullString
err := rows.Scan(&user.ID, &user.Username, &displayName, &user.PfpFileID, &bio, &user.AuthenticationString, &user.AuthenticationSalt, &user.AuthenticationAlgorithm, &createdAtString, &updatedAtString, &user.Verified, &user.Permissions)
if err != nil {
return nil, err
}
if displayName.Valid {
user.DisplayName = displayName.String
}
if bio.Valid {
user.Bio = bio.String
}
if createdAtString.Valid {
user.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtString.String)
if err != nil {
return nil, fmt.Errorf("error parsing created_at: %v", err)
}
}
if updatedAtString.Valid {
user.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtString.String)
if err != nil {
return nil, fmt.Errorf("error parsing updated_at: %v", err)
}
}
users = append(users, user)
}
return users, nil
}
func DeleteUser(db *sql.DB, userID int) error {
_, err := db.Exec("DELETE FROM users WHERE id = ?", userID)
return err
}
const ( const (
PermCreateBoard int64 = 1 << 0 PermCreateBoard int64 = 1 << 0
PermManageUsers int64 = 1 << 1 PermManageUsers int64 = 1 << 1

View File

@ -9,6 +9,7 @@
const boardId = chatContainer.dataset.boardId; const boardId = chatContainer.dataset.boardId;
const basePath = chatContainer.dataset.basePath || ''; const basePath = chatContainer.dataset.basePath || '';
const currentUsername = chatContainer.dataset.currentUsername || ''; const currentUsername = chatContainer.dataset.currentUsername || '';
const csrfToken = chatContainer.dataset.csrfToken || '';
const usernamesScript = document.getElementById('chat-usernames'); const usernamesScript = document.getElementById('chat-usernames');
let allUsernames = []; let allUsernames = [];
if (usernamesScript) { if (usernamesScript) {
@ -55,7 +56,7 @@
updateConnectionStatus('connecting'); updateConnectionStatus('connecting');
const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const query = new URLSearchParams({ ws: 'true', id: boardId }); const query = new URLSearchParams({ ws: 'true', id: boardId, csrf_token: csrfToken });
ws = new WebSocket(protocol + window.location.host + basePath + '/chat/?' + query.toString()); ws = new WebSocket(protocol + window.location.host + basePath + '/chat/?' + query.toString());
ws.onopen = function() { ws.onopen = function() {

View File

@ -5,6 +5,7 @@ function initLikeButtons() {
var postId = btn.getAttribute('data-post-id'); var postId = btn.getAttribute('data-post-id');
var type = btn.getAttribute('data-type'); var type = btn.getAttribute('data-type');
var basePath = btn.getAttribute('data-base-path'); var basePath = btn.getAttribute('data-base-path');
var csrfToken = document.body ? document.body.getAttribute('data-csrf-token') : '';
btn.disabled = true; btn.disabled = true;
@ -15,7 +16,8 @@ function initLikeButtons() {
fetch(basePath + '/like/', { fetch(basePath + '/like/', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded' 'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': csrfToken
}, },
body: body.toString() body: body.toString()
}) })

View File

@ -1,78 +0,0 @@
{{define "admin"}}
<!DOCTYPE html>
<html>
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
{{template "navbar" .}}
<main>
<header>
<h2>Admin Panel</h2>
</header>
{{if .ShowSuccess}}
<div class="notification success" style="position: static; margin-bottom: 1em; animation: none;">
Settings saved successfully!
</div>
{{end}}
{{if .ShowDeleted}}
<div class="notification success" style="position: static; margin-bottom: 1em; animation: none;">
User deleted successfully.
</div>
{{end}}
<section>
<h3>Registration</h3>
<form method="post" action="{{.BasePath}}/admin/">
<label for="allow_signup" style="display: flex; align-items: center; gap: 0.5em; cursor: pointer;">
<input type="checkbox" id="allow_signup" name="allow_signup" {{if .AllowSignup}}checked{{end}}>
<span>Allow new user signups</span>
</label>
<p style="margin-left: 1.5em; margin-top: 0.25em; font-size: 0.9em; opacity: 0.8;">
When disabled, the signup page redirects to login and prevents account creation.
</p>
<input type="submit" value="Save Settings" style="margin-top: 1.5em;">
</form>
</section>
<section>
<h3>User Management</h3>
{{if .Users}}
<table style="width: 100%; border-collapse: collapse; margin-top: 1em;">
<thead>
<tr style="border-bottom: 2px solid #f582ae;">
<th style="text-align: left; padding: 0.5em;">Username</th>
<th style="text-align: left; padding: 0.5em;">Display Name</th>
<th style="text-align: left; padding: 0.5em;">Joined</th>
<th style="text-align: right; padding: 0.5em;">Actions</th>
</tr>
</thead>
<tbody>
{{range .Users}}
<tr style="border-bottom: 1px solid #ddd;">
<td style="padding: 0.5em;">{{.Username}}{{if eq .ID $.CurrentUserID}} <em>(you)</em>{{end}}</td>
<td style="padding: 0.5em;">{{if .DisplayName}}{{.DisplayName}}{{else}}—{{end}}</td>
<td style="padding: 0.5em;">{{.CreatedAt.Format "02/01/2006"}}</td>
<td style="padding: 0.5em; text-align: right;">
{{if ne .ID $.CurrentUserID}}
<form method="post" action="{{$.BasePath}}/admin/?action=delete_user" style="display: inline;" onsubmit="return confirm('Permanently delete user \'{{.Username}}\'? This cannot be undone.')">
<input type="hidden" name="user_id" value="{{.ID}}">
<button type="submit" style="background: #f582ae; color: #001858; padding: 0.2em 0.6em; font-size: 0.85em;">Delete</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p>No users found.</p>
{{end}}
</section>
</main>
{{template "cookie_banner" .}}
</body>
</html>
{{end}}

View File

@ -40,6 +40,7 @@
<section> <section>
<h3>Create New Thread</h3> <h3>Create New Thread</h3>
<form method="post" action="{{.BasePath}}/board/?id={{.Board.ID}}&action=create_thread"> <form method="post" action="{{.BasePath}}/board/?id={{.Board.ID}}&action=create_thread">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label for="title">Thread Title:</label> <label for="title">Thread Title:</label>
<input type="text" id="title" name="title" required maxlength="255"><br> <input type="text" id="title" name="title" required maxlength="255"><br>
<input type="submit" value="Create Thread"> <input type="submit" value="Create Thread">

View File

@ -56,6 +56,7 @@
<section> <section>
<h3>Create New Public Board</h3> <h3>Create New Public Board</h3>
<form method="post" action="{{.BasePath}}/boards/"> <form method="post" action="{{.BasePath}}/boards/">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label for="name">Board Name:</label> <label for="name">Board Name:</label>
<input type="text" id="name" name="name" required maxlength="255"><br> <input type="text" id="name" name="name" required maxlength="255"><br>
<label for="description">Description:</label> <label for="description">Description:</label>

View File

@ -12,10 +12,10 @@
<script src="{{.StaticPath}}/app.js" defer></script> <script src="{{.StaticPath}}/app.js" defer></script>
<script src="{{.StaticPath}}/chat.js" defer></script> <script src="{{.StaticPath}}/chat.js" defer></script>
</head> </head>
<body class="chat-page"> <body class="chat-page" data-csrf-token="{{.CSRFToken}}">
{{template "navbar" .}} {{template "navbar" .}}
<main> <main>
<div class="chat-container" data-board-id="{{.Board.ID}}" data-base-path="{{.BasePath}}" data-current-username="{{.CurrentUsername}}"> <div class="chat-container" data-board-id="{{.Board.ID}}" data-base-path="{{.BasePath}}" data-current-username="{{.CurrentUsername}}" data-csrf-token="{{.CSRFToken}}">
<div class="chat-breadcrumb"> <div class="chat-breadcrumb">
<a href="{{.BasePath}}/">Home</a> <a href="{{.BasePath}}/">Home</a>
<span class="chat-breadcrumb-separator"></span> <span class="chat-breadcrumb-separator"></span>

View File

@ -17,6 +17,7 @@
<p class="field-error" style="text-align: center; font-size: 1em;">{{.Error}}</p> <p class="field-error" style="text-align: center; font-size: 1em;">{{.Error}}</p>
{{end}} {{end}}
<form method="post" action="{{.BasePath}}/login/"> <form method="post" action="{{.BasePath}}/login/">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label for="username">Username:</label> <label for="username">Username:</label>
<input type="text" id="username" name="username" required autocomplete="username"><br> <input type="text" id="username" name="username" required autocomplete="username"><br>
<label for="password">Password:</label> <label for="password">Password:</label>

View File

@ -20,6 +20,7 @@
<p>{{.Content}}</p> <p>{{.Content}}</p>
{{if $.IsAdmin}} {{if $.IsAdmin}}
<form method="post" action="{{$.BasePath}}/news/?action=delete&id={{.ID}}" style="display:inline;"> <form method="post" action="{{$.BasePath}}/news/?action=delete&id={{.ID}}" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" onclick="return confirm('Are you sure you want to delete this news item?')">Delete</button> <button type="submit" onclick="return confirm('Are you sure you want to delete this news item?')">Delete</button>
</form> </form>
{{end}} {{end}}
@ -34,6 +35,7 @@
<section> <section>
<h3>Post New Announcement</h3> <h3>Post New Announcement</h3>
<form method="post" action="{{.BasePath}}/news/"> <form method="post" action="{{.BasePath}}/news/">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label for="title">Title:</label> <label for="title">Title:</label>
<input type="text" id="title" name="title" required maxlength="255"><br> <input type="text" id="title" name="title" required maxlength="255"><br>
<label for="content">Content:</label> <label for="content">Content:</label>

View File

@ -1,38 +0,0 @@
{{define "password"}}
<!DOCTYPE html>
<html>
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
{{template "navbar" .}}
<main>
<header>
<h2>Change Password</h2>
</header>
{{if .ShowSuccess}}
<div class="notification success" style="position: static; margin-bottom: 1em; animation: none;">
Password changed successfully!
</div>
{{end}}
{{if .Error}}
<p class="field-error" style="text-align: center; font-size: 1em;">{{.Error}}</p>
{{end}}
<section>
<form method="post" action="{{.BasePath}}/password/">
<label for="current_password">Current Password:</label>
<input type="password" id="current_password" name="current_password" required autocomplete="current-password"><br>
<label for="new_password">New Password:</label>
<input type="password" id="new_password" name="new_password" required autocomplete="new-password" minlength="8" maxlength="128"><br>
<label for="confirm_password">Confirm New Password:</label>
<input type="password" id="confirm_password" name="confirm_password" required autocomplete="new-password" minlength="8" maxlength="128"><br>
<input type="submit" value="Change Password">
</form>
</section>
</main>
{{template "cookie_banner" .}}
</body>
</html>
{{end}}

View File

@ -11,6 +11,7 @@
{{end}} {{end}}
<section> <section>
<form method="post" action="{{.BasePath}}/preferences/"> <form method="post" action="{{.BasePath}}/preferences/">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<h3>Draft Auto-Save</h3> <h3>Draft Auto-Save</h3>
<label for="auto_save_drafts" style="display: flex; align-items: center; gap: 0.5em; cursor: pointer;"> <label for="auto_save_drafts" style="display: flex; align-items: center; gap: 0.5em; cursor: pointer;">
<input type="checkbox" id="auto_save_drafts" name="auto_save_drafts" {{if .Preferences.AutoSaveDrafts}}checked{{end}}> <input type="checkbox" id="auto_save_drafts" name="auto_save_drafts" {{if .Preferences.AutoSaveDrafts}}checked{{end}}>

View File

@ -14,6 +14,7 @@
</header> </header>
<section> <section>
<form method="post" action="{{.BasePath}}/profile/edit/" enctype="multipart/form-data"> <form method="post" action="{{.BasePath}}/profile/edit/" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label for="display_name">Display Name:</label> <label for="display_name">Display Name:</label>
<input type="text" id="display_name" name="display_name" value="{{.User.DisplayName}}" maxlength="255"><br> <input type="text" id="display_name" name="display_name" value="{{.User.DisplayName}}" maxlength="255"><br>
<label for="pfp">Profile Picture:</label> <label for="pfp">Profile Picture:</label>

View File

@ -17,6 +17,7 @@
<p class="field-error" style="text-align: center; font-size: 1em;">{{.Error}}</p> <p class="field-error" style="text-align: center; font-size: 1em;">{{.Error}}</p>
{{end}} {{end}}
<form method="post" action="{{.BasePath}}/signup/"> <form method="post" action="{{.BasePath}}/signup/">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label for="username">Username:</label> <label for="username">Username:</label>
<input type="text" id="username" name="username" required autocomplete="username" minlength="3" maxlength="30"><br> <input type="text" id="username" name="username" required autocomplete="username" minlength="3" maxlength="30"><br>
<label for="password">Password:</label> <label for="password">Password:</label>

View File

@ -7,7 +7,7 @@
<script src="{{.StaticPath}}/likes.js" defer></script> <script src="{{.StaticPath}}/likes.js" defer></script>
<script src="{{.StaticPath}}/app.js" defer></script> <script src="{{.StaticPath}}/app.js" defer></script>
</head> </head>
<body> <body data-csrf-token="{{.CSRFToken}}">
{{template "navbar" .}} {{template "navbar" .}}
<main> <main>
<div class="breadcrumb"> <div class="breadcrumb">
@ -62,6 +62,7 @@
<button type="button" onclick="clearReply()">x</button> <button type="button" onclick="clearReply()">x</button>
</div> </div>
<form method="post" action="{{.BasePath}}/thread/?id={{.Thread.ID}}&action=submit" id="reply-form"> <form method="post" action="{{.BasePath}}/thread/?id={{.Thread.ID}}&action=submit" id="reply-form">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" id="reply-to-input" name="reply_to" value=""> <input type="hidden" id="reply-to-input" name="reply_to" value="">
<label for="content">Content:</label> <label for="content">Content:</label>
<textarea id="content" name="content" required></textarea><br> <textarea id="content" name="content" required></textarea><br>
@ -105,11 +106,3 @@
</body> </body>
</html> </html>
{{end}} {{end}}
{ target.classList.remove('post-highlighted'); }, 2000);
}
});
});
</script>
</body>
</html>
{{end}}

View File

@ -14,12 +14,9 @@
</header> </header>
<section> <section>
<p>This is your user home page.</p> <p>This is your user home page.</p>
{{if .IsAdmin}}
<p><a href="{{.BasePath}}/admin/">Go to Admin Panel</a></p>
{{end}}
</section> </section>
</main> </main>
{{template "cookie_banner" .}} {{template "cookie_banner" .}}
</body> </body>
</html> </html>
{{end}} {{end}}

View File

@ -4,21 +4,15 @@
{{if .LoggedIn}} {{if .LoggedIn}}
<li><a {{if eq .Navbar "userhome"}}class="active"{{end}} href="{{.BasePath}}/userhome/">User Home</a></li> <li><a {{if eq .Navbar "userhome"}}class="active"{{end}} href="{{.BasePath}}/userhome/">User Home</a></li>
<li><a {{if eq .Navbar "profile"}}class="active"{{end}} href="{{.BasePath}}/profile/">Profile</a></li> <li><a {{if eq .Navbar "profile"}}class="active"{{end}} href="{{.BasePath}}/profile/">Profile</a></li>
<li><a {{if eq .Navbar "password"}}class="active"{{end}} href="{{.BasePath}}/password/">Password</a></li>
<li><a {{if eq .Navbar "preferences"}}class="active"{{end}} href="{{.BasePath}}/preferences/">Preferences</a></li> <li><a {{if eq .Navbar "preferences"}}class="active"{{end}} href="{{.BasePath}}/preferences/">Preferences</a></li>
{{if .IsAdmin}}
<li><a {{if eq .Navbar "admin"}}class="active"{{end}} href="{{.BasePath}}/admin/">Admin</a></li>
{{end}}
<li><a href="{{.BasePath}}/logout/">Logout</a></li> <li><a href="{{.BasePath}}/logout/">Logout</a></li>
{{else}} {{else}}
<li><a {{if eq .Navbar "login"}}class="active"{{end}} href="{{.BasePath}}/login/">Login</a></li> <li><a {{if eq .Navbar "login"}}class="active"{{end}} href="{{.BasePath}}/login/">Login</a></li>
{{if .AllowSignup}}
<li><a {{if eq .Navbar "signup"}}class="active"{{end}} href="{{.BasePath}}/signup/">Sign Up</a></li> <li><a {{if eq .Navbar "signup"}}class="active"{{end}} href="{{.BasePath}}/signup/">Sign Up</a></li>
{{end}} {{end}}
{{end}}
<li><a {{if eq .Navbar "boards"}}class="active"{{end}} href="{{.BasePath}}/boards/">Boards</a></li> <li><a {{if eq .Navbar "boards"}}class="active"{{end}} href="{{.BasePath}}/boards/">Boards</a></li>
<li><a {{if eq .Navbar "news"}}class="active"{{end}} href="{{.BasePath}}/news/">News</a></li> <li><a {{if eq .Navbar "news"}}class="active"{{end}} href="{{.BasePath}}/news/">News</a></li>
<li><a {{if eq .Navbar "about"}}class="active"{{end}} href="{{.BasePath}}/about/">About</a></li> <li><a {{if eq .Navbar "about"}}class="active"{{end}} href="{{.BasePath}}/about/">About</a></li>
</ul> </ul>
<div class="topnav"></div> <div class="topnav"></div>
{{end}} {{end}}