feat: add SMTP email support and fix profile/permissions bugs

This commit is contained in:
root
2026-06-30 19:29:08 +00:00
parent 4ded582dc4
commit f4437c5e1d
26 changed files with 643 additions and 129 deletions
+103 -26
View File
@@ -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)
}
}