feat: add SMTP email support and fix profile/permissions bugs
This commit is contained in:
@@ -17,7 +17,7 @@ func SetSessionCookie(w http.ResponseWriter, cookieName, token string, duration
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(duration.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
||||
)
|
||||
|
||||
func generateToken() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func (h *Handler) RequestVerification(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.getProfile(r.Context(), userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"user not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
token := generateToken()
|
||||
_, err = h.db.ExecContext(r.Context(), `
|
||||
INSERT INTO user_tokens (user_id, token, type, expires_at)
|
||||
VALUES ($1, $2, 'verify_email', NOW() + INTERVAL '24 hours')
|
||||
`, userID, token)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
appURL := "http://" + h.cfg.Host
|
||||
if h.cfg.Port != "80" && h.cfg.Port != "443" {
|
||||
appURL += ":" + h.cfg.Port
|
||||
}
|
||||
|
||||
if err := h.mailer.SendVerificationEmail(user.Email, user.Username, token, appURL); err != nil {
|
||||
http.Error(w, `{"error":"failed to send email"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var userID string
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
DELETE FROM user_tokens
|
||||
WHERE token = $1 AND type = 'verify_email' AND expires_at > NOW()
|
||||
RETURNING user_id
|
||||
`, req.Token).Scan(&userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.db.ExecContext(r.Context(), `
|
||||
UPDATE users SET email_verified = TRUE WHERE id = $1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to update user"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var userID, username string
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
SELECT id, username FROM users WHERE email = $1
|
||||
`, req.Email).Scan(&userID, &username)
|
||||
if err != nil {
|
||||
// Silent fail to prevent email enumeration
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
token := generateToken()
|
||||
_, err = h.db.ExecContext(r.Context(), `
|
||||
INSERT INTO user_tokens (user_id, token, type, expires_at)
|
||||
VALUES ($1, $2, 'reset_password', NOW() + INTERVAL '1 hour')
|
||||
`, userID, token)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
appURL := "http://" + h.cfg.Host
|
||||
if h.cfg.Port != "80" && h.cfg.Port != "443" {
|
||||
appURL += ":" + h.cfg.Port
|
||||
}
|
||||
|
||||
h.mailer.SendPasswordResetEmail(req.Email, username, token, appURL)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.NewPassword) < 8 {
|
||||
http.Error(w, `{"error":"password too short"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var userID string
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
DELETE FROM user_tokens
|
||||
WHERE token = $1 AND type = 'reset_password' AND expires_at > NOW()
|
||||
RETURNING user_id
|
||||
`, req.Token).Scan(&userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to hash password"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.db.ExecContext(r.Context(), `
|
||||
UPDATE users SET password_hash = $2 WHERE id = $1
|
||||
`, userID, hash)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to update user"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete all sessions to force re-login
|
||||
h.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE user_id = $1`, userID)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) AdminAnnounce(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is an admin across the whole system.
|
||||
// For now, let's assume if they have ADMINISTRATOR on ANY server, they can announce.
|
||||
// Or maybe there is a global admin flag? Let's check permissions.
|
||||
// Actually, let's just make sure they have MANAGE_SERVER on a specific server or something.
|
||||
// DumpsterChat doesn't have system-level admins out of the box, only Server-level.
|
||||
// Let's require them to be the owner of at least one server, or have ADMINISTRATOR on a server?
|
||||
// The prompt just said "admin sent announcements". Let's allow it for users who are in any server with ADMINISTRATOR.
|
||||
var isAdmin bool
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM member_roles mr
|
||||
JOIN roles r ON mr.role_id = r.id
|
||||
WHERE mr.user_id = $1 AND (r.permissions & $2) = $2
|
||||
)
|
||||
`, userID, permissions.ADMINISTRATOR).Scan(&isAdmin)
|
||||
|
||||
if err != nil || !isAdmin {
|
||||
http.Error(w, `{"error":"must be an administrator"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.db.QueryContext(r.Context(), `SELECT email FROM users`)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to fetch users"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var email string
|
||||
if err := rows.Scan(&email); err == nil && email != "" {
|
||||
// This is synchronous and could block, ideally use a queue, but this is fine for now
|
||||
h.mailer.SendAnnouncement(email, req.Subject, req.Body)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
+103
-26
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/email"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -21,14 +22,16 @@ type Handler struct {
|
||||
cfg *config.Config
|
||||
sessions *SessionStore
|
||||
hub *gateway.Hub
|
||||
mailer *email.Mailer
|
||||
}
|
||||
|
||||
func NewHandler(db *sql.DB, cfg *config.Config, hub *gateway.Hub) *Handler {
|
||||
func NewHandler(db *sql.DB, cfg *config.Config, hub *gateway.Hub, mailer *email.Mailer) *Handler {
|
||||
return &Handler{
|
||||
db: db,
|
||||
cfg: cfg,
|
||||
sessions: NewSessionStore(db, cfg),
|
||||
hub: hub,
|
||||
mailer: mailer,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,9 +39,14 @@ func (h *Handler) RegisterPublicRoutes(r chi.Router) {
|
||||
r.Post("/register", h.Register)
|
||||
r.Post("/login/password", h.Login)
|
||||
r.Post("/logout", h.Logout)
|
||||
r.Post("/verify-email", h.VerifyEmail)
|
||||
r.Post("/request-password-reset", h.RequestPasswordReset)
|
||||
r.Post("/reset-password", h.ResetPassword)
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterProtectedRoutes(r chi.Router) {
|
||||
r.Post("/auth/request-verification", h.RequestVerification)
|
||||
r.Post("/auth/admin/announce", h.AdminAnnounce)
|
||||
r.Get("/auth/me", h.Me)
|
||||
r.Patch("/auth/me", h.UpdateProfile)
|
||||
r.Put("/auth/me/password", h.ChangePassword)
|
||||
@@ -210,16 +218,16 @@ type loginRequest struct {
|
||||
}
|
||||
|
||||
type updateProfileRequest struct {
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Bio string `json:"bio"`
|
||||
AccentColor string `json:"accent_color"`
|
||||
StatusText string `json:"status_text"`
|
||||
Status string `json:"status"`
|
||||
BannerURL string `json:"banner_url"`
|
||||
Tagline string `json:"tagline"`
|
||||
Pronouns string `json:"pronouns"`
|
||||
SocialLinks []struct {
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
Bio *string `json:"bio"`
|
||||
AccentColor *string `json:"accent_color"`
|
||||
StatusText *string `json:"status_text"`
|
||||
Status *string `json:"status"`
|
||||
BannerURL *string `json:"banner_url"`
|
||||
Tagline *string `json:"tagline"`
|
||||
Pronouns *string `json:"pronouns"`
|
||||
SocialLinks *[]struct {
|
||||
Platform string `json:"platform"`
|
||||
URL string `json:"url"`
|
||||
} `json:"social_links"`
|
||||
@@ -410,8 +418,16 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /auth/me [get]
|
||||
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||
cookie, _ := r.Cookie(h.cfg.Session.CookieName)
|
||||
if cookie != nil {
|
||||
fmt.Printf("Me Request: Cookie present! value=%s\n", cookie.Value)
|
||||
} else {
|
||||
fmt.Printf("Me Request: NO COOKIE!\n")
|
||||
}
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
fmt.Printf("Me Request: NO USER ID IN CONTEXT\n")
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -450,52 +466,113 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Bio) > 250 {
|
||||
// Fetch existing user to merge fields
|
||||
var existing struct {
|
||||
DisplayName string
|
||||
Bio string
|
||||
AccentColor string
|
||||
StatusText string
|
||||
Status string
|
||||
BannerURL string
|
||||
Tagline string
|
||||
Pronouns string
|
||||
AvatarURL string
|
||||
SocialLinks []struct {
|
||||
Platform string `json:"platform"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
}
|
||||
var socialLinksBytes []byte
|
||||
var bannerURL, tagline, pronouns, avatar sql.NullString
|
||||
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
SELECT display_name, bio, accent_color, status_text, status, banner_url, tagline, pronouns, avatar, social_links
|
||||
FROM users WHERE id = $1
|
||||
`, userID).Scan(&existing.DisplayName, &existing.Bio, &existing.AccentColor, &existing.StatusText, &existing.Status, &bannerURL, &tagline, &pronouns, &avatar, &socialLinksBytes)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
existing.BannerURL = bannerURL.String
|
||||
existing.Tagline = tagline.String
|
||||
existing.Pronouns = pronouns.String
|
||||
existing.AvatarURL = avatar.String
|
||||
if len(socialLinksBytes) > 0 {
|
||||
json.Unmarshal(socialLinksBytes, &existing.SocialLinks)
|
||||
}
|
||||
|
||||
// Helper to merge strings
|
||||
mergeStr := func(ptr *string, old string) string {
|
||||
if ptr != nil {
|
||||
return *ptr
|
||||
}
|
||||
return old
|
||||
}
|
||||
|
||||
newBio := mergeStr(req.Bio, existing.Bio)
|
||||
newStatusText := mergeStr(req.StatusText, existing.StatusText)
|
||||
newDisplayName := mergeStr(req.DisplayName, existing.DisplayName)
|
||||
newTagline := mergeStr(req.Tagline, existing.Tagline)
|
||||
newPronouns := mergeStr(req.Pronouns, existing.Pronouns)
|
||||
newAccentColor := mergeStr(req.AccentColor, existing.AccentColor)
|
||||
newStatus := mergeStr(req.Status, existing.Status)
|
||||
newBannerURL := mergeStr(req.BannerURL, existing.BannerURL)
|
||||
newAvatarURL := mergeStr(req.AvatarURL, existing.AvatarURL)
|
||||
|
||||
if len(newBio) > 250 {
|
||||
http.Error(w, `{"error":"bio too long"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.StatusText) > 128 {
|
||||
if len(newStatusText) > 128 {
|
||||
http.Error(w, `{"error":"status text too long"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.DisplayName) > 32 {
|
||||
if len(newDisplayName) > 32 {
|
||||
http.Error(w, `{"error":"display name too long"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.Tagline) > 128 {
|
||||
if len(newTagline) > 128 {
|
||||
http.Error(w, `{"error":"tagline too long"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.Pronouns) > 40 {
|
||||
if len(newPronouns) > 40 {
|
||||
http.Error(w, `{"error":"pronouns too long"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.AccentColor != "" && len(req.AccentColor) != 7 {
|
||||
if newAccentColor != "" && len(newAccentColor) != 7 {
|
||||
http.Error(w, `{"error":"accent color must be 7 char hex"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Status != "" && !isValidStatus(req.Status) {
|
||||
if newStatus != "" && !isValidStatus(newStatus) {
|
||||
http.Error(w, `{"error":"invalid status"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
socialLinksJSON, _ := json.Marshal(req.SocialLinks)
|
||||
_, err := h.db.ExecContext(r.Context(), `
|
||||
var socialLinksJSON []byte
|
||||
if req.SocialLinks != nil {
|
||||
socialLinksJSON, _ = json.Marshal(*req.SocialLinks)
|
||||
} else {
|
||||
socialLinksJSON, _ = json.Marshal(existing.SocialLinks)
|
||||
}
|
||||
|
||||
_, err = h.db.ExecContext(r.Context(), `
|
||||
UPDATE users
|
||||
SET display_name = $2, bio = $3, accent_color = $4, status_text = $5, status = COALESCE(NULLIF($6, ''), status),
|
||||
banner_url = NULLIF($7, ''), tagline = NULLIF($8, ''), pronouns = NULLIF($9, ''), social_links = $10, avatar = COALESCE(NULLIF($11, ''), avatar)
|
||||
SET display_name = $2, bio = $3, accent_color = $4, status_text = $5, status = $6,
|
||||
banner_url = $7, tagline = $8, pronouns = $9, social_links = $10, avatar = $11
|
||||
WHERE id = $1
|
||||
`, userID, req.DisplayName, req.Bio, req.AccentColor, req.StatusText, req.Status, req.BannerURL, req.Tagline, req.Pronouns, socialLinksJSON, req.AvatarURL)
|
||||
`, userID, newDisplayName, newBio, newAccentColor, newStatusText, newStatus, newBannerURL, newTagline, newPronouns, socialLinksJSON, newAvatarURL)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status != "" && h.hub != nil {
|
||||
if req.Status != nil && h.hub != nil {
|
||||
user, err := h.getProfile(r.Context(), userID)
|
||||
if err == nil {
|
||||
h.hub.SetUserStatus(userID, req.Status)
|
||||
h.hub.BroadcastPresence(userID, user.Username, req.Status)
|
||||
h.hub.SetUserStatus(userID, *req.Status)
|
||||
h.hub.BroadcastPresence(userID, user.Username, *req.Status)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user