Compare commits

...

1 Commits

Author SHA1 Message Date
Joca 9138dfe650
Remove CSRF, add password change, admin user management
Stripped all CSRF token generation, injection, and validation since it
breaks behind Apache reverse proxy. Removed handlers/csrf.go, stripped
CSRFToken from PageData, removed validateCSRFToken from all POST handlers,
and cleaned up hidden inputs and JS CSRF references.

Added self-service password change at /password/ with current-password
verification and bcrypt update. New Password link in navbar.

Extended admin panel with user management: lists all users with join dates
and allows admins to delete other users (self-deletion blocked). Added
GetAllUsers() and DeleteUser() to models.
2026-05-13 18:01:03 -03:00
30 changed files with 283 additions and 142 deletions

View File

@ -3,6 +3,7 @@ package handlers
import ( import (
"log" "log"
"net/http" "net/http"
"strconv"
"threadr/models" "threadr/models"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
@ -31,8 +32,25 @@ func AdminHandler(app *App) http.HandlerFunc {
cookie, _ := r.Cookie("threadr_cookie_banner") cookie, _ := r.Cookie("threadr_cookie_banner")
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
if !app.validateCSRFToken(r, session) { action := r.URL.Query().Get("action")
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
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 return
} }
@ -59,10 +77,20 @@ func AdminHandler(app *App) http.HandlerFunc {
return 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 { data := struct {
PageData PageData
AllowSignup bool AllowSignup bool
ShowSuccess bool ShowSuccess bool
ShowDeleted bool
Users []models.User
CurrentUserID int
}{ }{
PageData: PageData{ PageData: PageData{
Title: "ThreadR - Admin", Title: "ThreadR - Admin",
@ -74,10 +102,12 @@ func AdminHandler(app *App) http.HandlerFunc {
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),
}, },
AllowSignup: settings.AllowSignup, AllowSignup: settings.AllowSignup,
ShowSuccess: r.URL.Query().Get("saved") == "true", 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 { if err := app.Tmpl.ExecuteTemplate(w, "admin", data); err != nil {

View File

@ -22,7 +22,6 @@ type PageData struct {
CurrentURL string CurrentURL string
ContentTemplate string ContentTemplate string
BodyClass string BodyClass string
CSRFToken string
} }
type Config struct { type Config struct {

View File

@ -58,11 +58,6 @@ 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)
@ -124,7 +119,6 @@ func BoardHandler(app *App) http.HandlerFunc {
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,11 +26,6 @@ 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")
@ -112,7 +107,6 @@ func BoardsHandler(app *App) http.HandlerFunc {
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,11 +147,6 @@ 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)
@ -246,7 +241,6 @@ func ChatHandler(app *App) http.HandlerFunc {
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,

View File

@ -1,55 +0,0 @@
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

@ -23,11 +23,6 @@ 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,11 +13,6 @@ 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)
@ -59,7 +54,6 @@ func LoginHandler(app *App) http.HandlerFunc {
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

@ -26,11 +26,6 @@ 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)
@ -91,7 +86,6 @@ func NewsHandler(app *App) http.HandlerFunc {
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,

96
handlers/password.go Normal file
View File

@ -0,0 +1,96 @@
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,11 +19,6 @@ 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"
@ -76,7 +71,6 @@ func PreferencesHandler(app *App) http.HandlerFunc {
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

@ -34,11 +34,6 @@ 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
@ -138,7 +133,6 @@ func ProfileEditHandler(app *App) http.HandlerFunc {
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

@ -24,11 +24,6 @@ func SignupHandler(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
}
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")
@ -49,7 +44,7 @@ func SignupHandler(app *App) http.HandlerFunc {
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.",
} }
@ -77,7 +72,7 @@ func SignupHandler(app *App) http.HandlerFunc {
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.",
} }
@ -104,7 +99,7 @@ func SignupHandler(app *App) http.HandlerFunc {
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

@ -59,11 +59,6 @@ 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 == "" {
@ -170,7 +165,6 @@ func ThreadHandler(app *App) http.HandlerFunc {
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

@ -439,6 +439,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("/admin/", handlers.AdminHandler(app))
handleAuthed("/like/", handlers.LikeHandler(app)) handleAuthed("/like/", handlers.LikeHandler(app))

View File

@ -168,6 +168,68 @@ 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,7 +9,6 @@
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) {
@ -56,7 +55,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, csrf_token: csrfToken }); const query = new URLSearchParams({ ws: 'true', id: boardId });
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,7 +5,6 @@ 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;
@ -16,8 +15,7 @@ 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

@ -17,10 +17,14 @@
Settings saved successfully! Settings saved successfully!
</div> </div>
{{end}} {{end}}
{{if .ShowDeleted}}
<div class="notification success" style="position: static; margin-bottom: 1em; animation: none;">
User deleted successfully.
</div>
{{end}}
<section> <section>
<h3>Registration</h3> <h3>Registration</h3>
<form method="post" action="{{.BasePath}}/admin/"> <form method="post" action="{{.BasePath}}/admin/">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label for="allow_signup" style="display: flex; align-items: center; gap: 0.5em; cursor: pointer;"> <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}}> <input type="checkbox" id="allow_signup" name="allow_signup" {{if .AllowSignup}}checked{{end}}>
<span>Allow new user signups</span> <span>Allow new user signups</span>
@ -32,6 +36,41 @@
<input type="submit" value="Save Settings" style="margin-top: 1.5em;"> <input type="submit" value="Save Settings" style="margin-top: 1.5em;">
</form> </form>
</section> </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> </main>
{{template "cookie_banner" .}} {{template "cookie_banner" .}}
</body> </body>

View File

@ -40,7 +40,6 @@
<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,7 +56,6 @@
<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" data-csrf-token="{{.CSRFToken}}"> <body class="chat-page">
{{template "navbar" .}} {{template "navbar" .}}
<main> <main>
<div class="chat-container" data-board-id="{{.Board.ID}}" data-base-path="{{.BasePath}}" data-current-username="{{.CurrentUsername}}" data-csrf-token="{{.CSRFToken}}"> <div class="chat-container" data-board-id="{{.Board.ID}}" data-base-path="{{.BasePath}}" data-current-username="{{.CurrentUsername}}">
<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,7 +17,6 @@
<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,7 +20,6 @@
<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}}
@ -35,7 +34,6 @@
<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

@ -0,0 +1,38 @@
{{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,7 +11,6 @@
{{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,7 +14,6 @@
</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,7 +17,6 @@
<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 data-csrf-token="{{.CSRFToken}}"> <body>
{{template "navbar" .}} {{template "navbar" .}}
<main> <main>
<div class="breadcrumb"> <div class="breadcrumb">
@ -62,7 +62,6 @@
<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>

View File

@ -4,6 +4,7 @@
{{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}} {{if .IsAdmin}}
<li><a {{if eq .Navbar "admin"}}class="active"{{end}} href="{{.BasePath}}/admin/">Admin</a></li> <li><a {{if eq .Navbar "admin"}}class="active"{{end}} href="{{.BasePath}}/admin/">Admin</a></li>