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",
Navbar: "about",
LoggedIn: loggedIn,
AllowSignup: app.allowSignup(),
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
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
Navbar string
LoggedIn bool
IsAdmin bool
AllowSignup bool
ShowCookieBanner bool
BasePath string
StaticPath string
CurrentURL string
ContentTemplate string
BodyClass string
CSRFToken string
}
type Config struct {

View File

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

View File

@ -26,6 +26,11 @@ func BoardsHandler(app *App) http.HandlerFunc {
}
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")
description := r.FormValue("description")
boardType := r.FormValue("type")
@ -102,11 +107,11 @@ func BoardsHandler(app *App) http.HandlerFunc {
Title: "ThreadR - Boards",
Navbar: "boards",
LoggedIn: loggedIn,
AllowSignup: app.allowSignup(),
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(),
CSRFToken: app.csrfToken(session),
},
PublicBoards: publicBoards,
PrivateBoards: privateBoards,

View File

@ -147,6 +147,11 @@ func ChatHandler(app *App) http.HandlerFunc {
currentUsername := currentUser.Username
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)
if err != nil {
log.Printf("Error upgrading to WebSocket: %v", err)
@ -234,13 +239,13 @@ func ChatHandler(app *App) http.HandlerFunc {
Title: "ThreadR Chat - " + board.Name,
Navbar: "boards",
LoggedIn: true,
AllowSignup: app.allowSignup(),
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(),
ContentTemplate: "chat-content",
BodyClass: "chat-page",
CSRFToken: app.csrfToken(session),
},
Board: *board,
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",
Navbar: "home",
LoggedIn: loggedIn,
AllowSignup: app.allowSignup(),
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
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)
return
}
if !app.validateCSRFToken(r, session) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
postIDStr := r.FormValue("post_id")
postID, err := strconv.Atoi(postIDStr)
if err != nil {

View File

@ -13,6 +13,11 @@ func LoginHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
if r.Method == http.MethodPost {
if !app.validateCSRFToken(r, session) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
user, err := models.GetUserByUsername(app.DB, username)
@ -50,17 +55,15 @@ func LoginHandler(app *App) http.HandlerFunc {
Title: "ThreadR - Login",
Navbar: "login",
LoggedIn: false,
AllowSignup: app.allowSignup(),
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(),
CSRFToken: app.csrfToken(session),
},
Error: "",
}
if r.URL.Query().Get("error") == "invalid" {
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 {

View File

@ -26,6 +26,11 @@ func NewsHandler(app *App) http.HandlerFunc {
}
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" {
newsIDStr := r.URL.Query().Get("id")
newsID, err := strconv.Atoi(newsIDStr)
@ -81,11 +86,11 @@ func NewsHandler(app *App) http.HandlerFunc {
Title: "ThreadR - News",
Navbar: "news",
LoggedIn: loggedIn,
AllowSignup: app.allowSignup(),
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(),
CSRFToken: app.csrfToken(session),
},
News: newsItems,
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)
if r.Method == http.MethodPost {
if !app.validateCSRFToken(r, session) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
// Get form values
autoSaveDrafts := r.FormValue("auto_save_drafts") == "on"
@ -65,12 +70,12 @@ func PreferencesHandler(app *App) http.HandlerFunc {
Title: "ThreadR - Preferences",
Navbar: "preferences",
LoggedIn: true,
AllowSignup: app.allowSignup(),
ShowCookieBanner: false,
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(),
ContentTemplate: "preferences-content",
CSRFToken: app.csrfToken(session),
},
Preferences: prefs,
ShowSuccess: showSuccess,

View File

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

View File

@ -34,6 +34,11 @@ func ProfileEditHandler(app *App) http.HandlerFunc {
}
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))
// Handle file upload
@ -128,11 +133,11 @@ func ProfileEditHandler(app *App) http.HandlerFunc {
Title: "ThreadR - Edit Profile",
Navbar: "profile",
LoggedIn: true,
AllowSignup: app.allowSignup(),
ShowCookieBanner: false,
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(),
CSRFToken: app.csrfToken(session),
},
User: *user,
}

View File

@ -11,19 +11,12 @@ func SignupHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
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 !app.validateCSRFToken(r, session) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
passwordConfirm := r.FormValue("password_confirm")
@ -39,12 +32,11 @@ func SignupHandler(app *App) http.HandlerFunc {
Title: "ThreadR - Sign Up",
Navbar: "signup",
LoggedIn: false,
AllowSignup: true,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(),
CSRFToken: app.csrfToken(session),
},
Error: "Passwords do not match. Please try again.",
}
@ -67,12 +59,11 @@ func SignupHandler(app *App) http.HandlerFunc {
Title: "ThreadR - Sign Up",
Navbar: "signup",
LoggedIn: false,
AllowSignup: true,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(),
CSRFToken: app.csrfToken(session),
},
Error: "An error occurred during sign up. Please try again.",
}
@ -94,12 +85,11 @@ func SignupHandler(app *App) http.HandlerFunc {
Title: "ThreadR - Sign Up",
Navbar: "signup",
LoggedIn: session.Values["user_id"] != nil,
AllowSignup: true,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(),
CSRFToken: app.csrfToken(session),
},
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 {
action := r.URL.Query().Get("action")
if action == "submit" {
if !app.validateCSRFToken(r, session) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
content := r.FormValue("content")
replyToStr := r.FormValue("reply_to")
if replyToStr == "" {
@ -160,11 +165,11 @@ func ThreadHandler(app *App) http.HandlerFunc {
Title: "ThreadR - " + thread.Title,
Navbar: "boards",
LoggedIn: loggedIn,
AllowSignup: app.allowSignup(),
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(),
CSRFToken: app.csrfToken(session),
},
Thread: *thread,
Board: *board,

View File

@ -25,9 +25,6 @@ func UserHomeHandler(app *App) http.HandlerFunc {
http.Error(w, "User not found", http.StatusNotFound)
return
}
isAdmin := models.HasGlobalPermission(user, models.PermManageUsers)
data := struct {
PageData
Username string
@ -36,8 +33,6 @@ func UserHomeHandler(app *App) http.HandlerFunc {
Title: "ThreadR - User Home",
Navbar: "userhome",
LoggedIn: true,
IsAdmin: isAdmin,
AllowSignup: app.allowSignup(),
ShowCookieBanner: false,
BasePath: app.Config.ThreadrDir,
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)
}
// 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.")
return nil
}
@ -357,11 +351,6 @@ func main() {
// Normal startup (without automatic table creation)
log.Println("Starting ThreadR server...")
err = models.EnsureSiteSettings(db)
if err != nil {
log.Fatal("Error ensuring site_settings table:", err)
}
dir, err := os.Getwd()
if err != nil {
log.Fatal("Error getting working directory:", err)
@ -375,7 +364,6 @@ func main() {
// Parse page-specific templates with unique names
pageTemplates := []string{
"admin.html",
"about.html",
"board.html",
"boards.html",
@ -439,9 +427,7 @@ func main() {
handle("/about/", handlers.AboutHandler(app))
handleAuthed("/profile/", handlers.ProfileHandler(app))
handleAuthed("/profile/edit/", handlers.ProfileEditHandler(app))
handleAuthed("/password/", handlers.PasswordHandler(app))
handleAuthed("/preferences/", handlers.PreferencesHandler(app))
handleAuthed("/admin/", handlers.AdminHandler(app))
handleAuthed("/like/", handlers.LikeHandler(app))
handle("/news/", handlers.NewsHandler(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
}
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 (
PermCreateBoard int64 = 1 << 0
PermManageUsers int64 = 1 << 1

View File

@ -9,6 +9,7 @@
const boardId = chatContainer.dataset.boardId;
const basePath = chatContainer.dataset.basePath || '';
const currentUsername = chatContainer.dataset.currentUsername || '';
const csrfToken = chatContainer.dataset.csrfToken || '';
const usernamesScript = document.getElementById('chat-usernames');
let allUsernames = [];
if (usernamesScript) {
@ -55,7 +56,7 @@
updateConnectionStatus('connecting');
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.onopen = function() {

View File

@ -5,6 +5,7 @@ function initLikeButtons() {
var postId = btn.getAttribute('data-post-id');
var type = btn.getAttribute('data-type');
var basePath = btn.getAttribute('data-base-path');
var csrfToken = document.body ? document.body.getAttribute('data-csrf-token') : '';
btn.disabled = true;
@ -15,7 +16,8 @@ function initLikeButtons() {
fetch(basePath + '/like/', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': csrfToken
},
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>
<h3>Create New Thread</h3>
<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>
<input type="text" id="title" name="title" required maxlength="255"><br>
<input type="submit" value="Create Thread">

View File

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

View File

@ -12,10 +12,10 @@
<script src="{{.StaticPath}}/app.js" defer></script>
<script src="{{.StaticPath}}/chat.js" defer></script>
</head>
<body class="chat-page">
<body class="chat-page" data-csrf-token="{{.CSRFToken}}">
{{template "navbar" .}}
<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">
<a href="{{.BasePath}}/">Home</a>
<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>
{{end}}
<form method="post" action="{{.BasePath}}/login/">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required autocomplete="username"><br>
<label for="password">Password:</label>

View File

@ -20,6 +20,7 @@
<p>{{.Content}}</p>
{{if $.IsAdmin}}
<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>
</form>
{{end}}
@ -34,6 +35,7 @@
<section>
<h3>Post New Announcement</h3>
<form method="post" action="{{.BasePath}}/news/">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label for="title">Title:</label>
<input type="text" id="title" name="title" required maxlength="255"><br>
<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}}
<section>
<form method="post" action="{{.BasePath}}/preferences/">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<h3>Draft Auto-Save</h3>
<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}}>

View File

@ -14,6 +14,7 @@
</header>
<section>
<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>
<input type="text" id="display_name" name="display_name" value="{{.User.DisplayName}}" maxlength="255"><br>
<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>
{{end}}
<form method="post" action="{{.BasePath}}/signup/">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required autocomplete="username" minlength="3" maxlength="30"><br>
<label for="password">Password:</label>

View File

@ -7,7 +7,7 @@
<script src="{{.StaticPath}}/likes.js" defer></script>
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
<body data-csrf-token="{{.CSRFToken}}">
{{template "navbar" .}}
<main>
<div class="breadcrumb">
@ -62,6 +62,7 @@
<button type="button" onclick="clearReply()">x</button>
</div>
<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="">
<label for="content">Content:</label>
<textarea id="content" name="content" required></textarea><br>
@ -105,11 +106,3 @@
</body>
</html>
{{end}}
{ target.classList.remove('post-highlighted'); }, 2000);
}
});
});
</script>
</body>
</html>
{{end}}

View File

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

View File

@ -4,18 +4,12 @@
{{if .LoggedIn}}
<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 "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>
{{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>
{{else}}
<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>
{{end}}
{{end}}
<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 "about"}}class="active"{{end}} href="{{.BasePath}}/about/">About</a></li>