diff --git a/handlers/admin.go b/handlers/admin.go index 807f169..89ff716 100644 --- a/handlers/admin.go +++ b/handlers/admin.go @@ -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 { diff --git a/handlers/app.go b/handlers/app.go index b3aa03f..0c96386 100644 --- a/handlers/app.go +++ b/handlers/app.go @@ -22,7 +22,6 @@ type PageData struct { CurrentURL string ContentTemplate string BodyClass string - CSRFToken string } type Config struct { diff --git a/handlers/board.go b/handlers/board.go index 5ac8ef3..48ec580 100644 --- a/handlers/board.go +++ b/handlers/board.go @@ -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, diff --git a/handlers/boards.go b/handlers/boards.go index 6b0f192..2781537 100644 --- a/handlers/boards.go +++ b/handlers/boards.go @@ -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, diff --git a/handlers/chat.go b/handlers/chat.go index 8a7eeeb..53c7f98 100644 --- a/handlers/chat.go +++ b/handlers/chat.go @@ -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, diff --git a/handlers/csrf.go b/handlers/csrf.go deleted file mode 100644 index 9b7eee8..0000000 --- a/handlers/csrf.go +++ /dev/null @@ -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 -} diff --git a/handlers/like.go b/handlers/like.go index a561292..5cd1a43 100644 --- a/handlers/like.go +++ b/handlers/like.go @@ -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 { diff --git a/handlers/login.go b/handlers/login.go index e7d248d..a8d6e70 100644 --- a/handlers/login.go +++ b/handlers/login.go @@ -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: "", } diff --git a/handlers/news.go b/handlers/news.go index 7c1acf6..ca3e737 100644 --- a/handlers/news.go +++ b/handlers/news.go @@ -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, diff --git a/handlers/password.go b/handlers/password.go new file mode 100644 index 0000000..aaf2802 --- /dev/null +++ b/handlers/password.go @@ -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) + } +} diff --git a/handlers/preferences.go b/handlers/preferences.go index afd377a..92fce6d 100644 --- a/handlers/preferences.go +++ b/handlers/preferences.go @@ -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, diff --git a/handlers/profile_edit.go b/handlers/profile_edit.go index 759a409..04dfed6 100644 --- a/handlers/profile_edit.go +++ b/handlers/profile_edit.go @@ -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, } diff --git a/handlers/signup.go b/handlers/signup.go index 8347e60..a049231 100644 --- a/handlers/signup.go +++ b/handlers/signup.go @@ -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: "", } diff --git a/handlers/thread.go b/handlers/thread.go index 9218584..aead236 100644 --- a/handlers/thread.go +++ b/handlers/thread.go @@ -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, diff --git a/main.go b/main.go index c8a237f..c3105d1 100644 --- a/main.go +++ b/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)) diff --git a/models/user.go b/models/user.go index a7a2f1d..cb612bb 100644 --- a/models/user.go +++ b/models/user.go @@ -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 diff --git a/static/chat.js b/static/chat.js index 1387049..8219482 100644 --- a/static/chat.js +++ b/static/chat.js @@ -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() { diff --git a/static/likes.js b/static/likes.js index 3430aa3..3372e87 100644 --- a/static/likes.js +++ b/static/likes.js @@ -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() }) diff --git a/templates/pages/admin.html b/templates/pages/admin.html index 1c131b3..78634de 100644 --- a/templates/pages/admin.html +++ b/templates/pages/admin.html @@ -17,10 +17,14 @@ Settings saved successfully! {{end}} + {{if .ShowDeleted}} +
| Username | +Display Name | +Joined | +Actions | +
|---|---|---|---|
| {{.Username}}{{if eq .ID $.CurrentUserID}} (you){{end}} | +{{if .DisplayName}}{{.DisplayName}}{{else}}—{{end}} | +{{.CreatedAt.Format "02/01/2006"}} | ++ {{if ne .ID $.CurrentUserID}} + + {{end}} + | +
No users found.
+ {{end}} +