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.jocadbz
parent
a5a2e7063a
commit
9138dfe650
|
|
@ -3,6 +3,7 @@ package handlers
|
|||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"threadr/models"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
|
|
@ -31,8 +32,25 @@ func AdminHandler(app *App) http.HandlerFunc {
|
|||
cookie, _ := r.Cookie("threadr_cookie_banner")
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
if !app.validateCSRFToken(r, session) {
|
||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -59,10 +77,20 @@ func AdminHandler(app *App) http.HandlerFunc {
|
|||
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
|
||||
AllowSignup bool
|
||||
ShowSuccess bool
|
||||
ShowDeleted bool
|
||||
Users []models.User
|
||||
CurrentUserID int
|
||||
}{
|
||||
PageData: PageData{
|
||||
Title: "ThreadR - Admin",
|
||||
|
|
@ -74,10 +102,12 @@ func AdminHandler(app *App) http.HandlerFunc {
|
|||
BasePath: app.Config.ThreadrDir,
|
||||
StaticPath: app.Config.ThreadrDir + "/static",
|
||||
CurrentURL: r.URL.RequestURI(),
|
||||
CSRFToken: app.csrfToken(session),
|
||||
},
|
||||
AllowSignup: settings.AllowSignup,
|
||||
ShowSuccess: r.URL.Query().Get("saved") == "true",
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ type PageData struct {
|
|||
CurrentURL string
|
||||
ContentTemplate string
|
||||
BodyClass string
|
||||
CSRFToken string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
|
|
|
|||
|
|
@ -58,11 +58,6 @@ 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)
|
||||
|
|
@ -124,7 +119,6 @@ func BoardHandler(app *App) http.HandlerFunc {
|
|||
BasePath: app.Config.ThreadrDir,
|
||||
StaticPath: app.Config.ThreadrDir + "/static",
|
||||
CurrentURL: r.URL.RequestURI(),
|
||||
CSRFToken: app.csrfToken(session),
|
||||
},
|
||||
Board: *board,
|
||||
Threads: threads,
|
||||
|
|
|
|||
|
|
@ -26,11 +26,6 @@ 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")
|
||||
|
|
@ -112,7 +107,6 @@ func BoardsHandler(app *App) http.HandlerFunc {
|
|||
BasePath: app.Config.ThreadrDir,
|
||||
StaticPath: app.Config.ThreadrDir + "/static",
|
||||
CurrentURL: r.URL.RequestURI(),
|
||||
CSRFToken: app.csrfToken(session),
|
||||
},
|
||||
PublicBoards: publicBoards,
|
||||
PrivateBoards: privateBoards,
|
||||
|
|
|
|||
|
|
@ -147,11 +147,6 @@ 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)
|
||||
|
|
@ -246,7 +241,6 @@ func ChatHandler(app *App) http.HandlerFunc {
|
|||
CurrentURL: r.URL.RequestURI(),
|
||||
ContentTemplate: "chat-content",
|
||||
BodyClass: "chat-page",
|
||||
CSRFToken: app.csrfToken(session),
|
||||
},
|
||||
Board: *board,
|
||||
Messages: messages,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -23,11 +23,6 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -13,11 +13,6 @@ 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)
|
||||
|
|
@ -59,7 +54,6 @@ func LoginHandler(app *App) http.HandlerFunc {
|
|||
BasePath: app.Config.ThreadrDir,
|
||||
StaticPath: app.Config.ThreadrDir + "/static",
|
||||
CurrentURL: r.URL.RequestURI(),
|
||||
CSRFToken: app.csrfToken(session),
|
||||
},
|
||||
Error: "",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,11 +26,6 @@ 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)
|
||||
|
|
@ -91,7 +86,6 @@ func NewsHandler(app *App) http.HandlerFunc {
|
|||
BasePath: app.Config.ThreadrDir,
|
||||
StaticPath: app.Config.ThreadrDir + "/static",
|
||||
CurrentURL: r.URL.RequestURI(),
|
||||
CSRFToken: app.csrfToken(session),
|
||||
},
|
||||
News: newsItems,
|
||||
IsAdmin: isAdmin,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -19,11 +19,6 @@ 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"
|
||||
|
||||
|
|
@ -76,7 +71,6 @@ func PreferencesHandler(app *App) http.HandlerFunc {
|
|||
StaticPath: app.Config.ThreadrDir + "/static",
|
||||
CurrentURL: r.URL.RequestURI(),
|
||||
ContentTemplate: "preferences-content",
|
||||
CSRFToken: app.csrfToken(session),
|
||||
},
|
||||
Preferences: prefs,
|
||||
ShowSuccess: showSuccess,
|
||||
|
|
|
|||
|
|
@ -34,11 +34,6 @@ 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
|
||||
|
|
@ -138,7 +133,6 @@ func ProfileEditHandler(app *App) http.HandlerFunc {
|
|||
BasePath: app.Config.ThreadrDir,
|
||||
StaticPath: app.Config.ThreadrDir + "/static",
|
||||
CurrentURL: r.URL.RequestURI(),
|
||||
CSRFToken: app.csrfToken(session),
|
||||
},
|
||||
User: *user,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,11 +24,6 @@ func SignupHandler(app *App) http.HandlerFunc {
|
|||
}
|
||||
|
||||
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")
|
||||
|
|
@ -49,7 +44,7 @@ func SignupHandler(app *App) http.HandlerFunc {
|
|||
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.",
|
||||
}
|
||||
|
|
@ -77,7 +72,7 @@ func SignupHandler(app *App) http.HandlerFunc {
|
|||
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.",
|
||||
}
|
||||
|
|
@ -104,7 +99,7 @@ func SignupHandler(app *App) http.HandlerFunc {
|
|||
BasePath: app.Config.ThreadrDir,
|
||||
StaticPath: app.Config.ThreadrDir + "/static",
|
||||
CurrentURL: r.URL.RequestURI(),
|
||||
CSRFToken: app.csrfToken(session),
|
||||
|
||||
},
|
||||
Error: "",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,11 +59,6 @@ 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 == "" {
|
||||
|
|
@ -170,7 +165,6 @@ func ThreadHandler(app *App) http.HandlerFunc {
|
|||
BasePath: app.Config.ThreadrDir,
|
||||
StaticPath: app.Config.ThreadrDir + "/static",
|
||||
CurrentURL: r.URL.RequestURI(),
|
||||
CSRFToken: app.csrfToken(session),
|
||||
},
|
||||
Thread: *thread,
|
||||
Board: *board,
|
||||
|
|
|
|||
1
main.go
1
main.go
|
|
@ -439,6 +439,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))
|
||||
|
|
|
|||
|
|
@ -168,6 +168,68 @@ 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
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
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) {
|
||||
|
|
@ -56,7 +55,7 @@
|
|||
updateConnectionStatus('connecting');
|
||||
|
||||
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.onopen = function() {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ 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;
|
||||
|
||||
|
|
@ -16,8 +15,7 @@ function initLikeButtons() {
|
|||
fetch(basePath + '/like/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': csrfToken
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: body.toString()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -17,10 +17,14 @@
|
|||
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/">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<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>
|
||||
|
|
@ -32,6 +36,41 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@
|
|||
<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">
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@
|
|||
<script src="{{.StaticPath}}/app.js" defer></script>
|
||||
<script src="{{.StaticPath}}/chat.js" defer></script>
|
||||
</head>
|
||||
<body class="chat-page" data-csrf-token="{{.CSRFToken}}">
|
||||
<body class="chat-page">
|
||||
{{template "navbar" .}}
|
||||
<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">
|
||||
<a href="{{.BasePath}}/">Home</a>
|
||||
<span class="chat-breadcrumb-separator">›</span>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@
|
|||
<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}}
|
||||
|
|
@ -35,7 +34,6 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
@ -11,7 +11,6 @@
|
|||
{{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}}>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<script src="{{.StaticPath}}/likes.js" defer></script>
|
||||
<script src="{{.StaticPath}}/app.js" defer></script>
|
||||
</head>
|
||||
<body data-csrf-token="{{.CSRFToken}}">
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
<main>
|
||||
<div class="breadcrumb">
|
||||
|
|
@ -62,7 +62,6 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
{{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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue