package auth import ( "context" "crypto/md5" "database/sql" "encoding/hex" "encoding/json" "fmt" "net/http" "strings" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" "github.com/go-chi/chi/v5" ) type Handler struct { db *sql.DB cfg *config.Config sessions *SessionStore hub *gateway.Hub } func NewHandler(db *sql.DB, cfg *config.Config, hub *gateway.Hub) *Handler { return &Handler{ db: db, cfg: cfg, sessions: NewSessionStore(db, cfg), hub: hub, } } func (h *Handler) RegisterPublicRoutes(r chi.Router) { r.Post("/register", h.Register) r.Post("/login/password", h.Login) r.Post("/logout", h.Logout) } func (h *Handler) RegisterProtectedRoutes(r chi.Router) { r.Get("/auth/me", h.Me) r.Patch("/auth/me", h.UpdateProfile) r.Put("/auth/me/password", h.ChangePassword) r.Get("/users/{userID}/profile", h.GetPublicProfile) r.Get("/users/me/blocks", h.ListBlocks) r.Post("/users/me/blocks", h.BlockUser) r.Delete("/users/me/blocks/{userID}", h.UnblockUser) } // @Summary Get public user profile // @Description Get public profile for any user // @Tags users // @Produce json // @Param userID path string true "User ID" // @Success 200 {object} PublicUserProfile // @Router /users/{userID}/profile [get] func (h *Handler) GetPublicProfile(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "userID") profile, err := h.getPublicProfile(r.Context(), userID) if err != nil { http.Error(w, `{"error":"user not found"}`, http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(profile) } func (h *Handler) getPublicProfile(ctx context.Context, userID string) (PublicUserProfile, error) { var p PublicUserProfile var socialLinks []byte err := h.db.QueryRowContext(ctx, ` SELECT id, username, display_name, email, avatar, bio, accent_color, status, status_text, created_at, banner_url, tagline, pronouns, social_links FROM users WHERE id = $1 `, userID).Scan(&p.ID, &p.Username, &p.DisplayName, &p.Email, &p.Avatar, &p.Bio, &p.AccentColor, &p.Status, &p.StatusText, &p.CreatedAt, &p.BannerURL, &p.Tagline, &p.Pronouns, &socialLinks) if err != nil { return p, err } if len(socialLinks) > 0 { _ = json.Unmarshal(socialLinks, &p.SocialLinks) } p.Badges = h.getUserBadges(ctx, userID) return p, nil } func (h *Handler) getUserBadges(ctx context.Context, userID string) []badgeResponse { rows, err := h.db.QueryContext(ctx, ` SELECT b.id, b.name, b.icon, b.description, b.server_id FROM badges b JOIN user_badges ub ON ub.badge_id = b.id WHERE ub.user_id = $1 `, userID) if err != nil { return nil } defer rows.Close() badges := make([]badgeResponse, 0) for rows.Next() { var b badgeResponse var serverID sql.NullString if err := rows.Scan(&b.ID, &b.Name, &b.Icon, &b.Description, &serverID); err != nil { continue } if serverID.Valid { b.ServerID = serverID.String } badges = append(badges, b) } return badges } // @Summary List blocked users // @Description List users the current user has blocked // @Tags users // @Produce json // @Success 200 {array} blockResponse // @Router /users/me/blocks [get] func (h *Handler) ListBlocks(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } rows, err := h.db.QueryContext(r.Context(), ` SELECT u.id, u.username, b.created_at::text FROM blocks b JOIN users u ON u.id = b.blocked_id WHERE b.blocker_id = $1 `, userID) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } defer rows.Close() blocks := make([]blockResponse, 0) for rows.Next() { var b blockResponse if err := rows.Scan(&b.ID, &b.Username, &b.CreatedAt); err != nil { continue } blocks = append(blocks, b) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(blocks) } // @Summary Block a user // @Description Block a user by ID // @Tags users // @Accept json // @Success 204 // @Router /users/me/blocks [post] func (h *Handler) BlockUser(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 { UserID string `json:"user_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID == "" { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } _, err := h.db.ExecContext(r.Context(), ` INSERT INTO blocks (blocker_id, blocked_id) VALUES ($1, $2) ON CONFLICT DO NOTHING `, userID, req.UserID) if err != nil { http.Error(w, `{"error":"failed to block user"}`, http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // @Summary Unblock a user // @Description Unblock a user by ID // @Tags users // @Success 204 // @Router /users/me/blocks/{userID} [delete] func (h *Handler) UnblockUser(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } targetID := chi.URLParam(r, "userID") _, err := h.db.ExecContext(r.Context(), `DELETE FROM blocks WHERE blocker_id = $1 AND blocked_id = $2`, userID, targetID) if err != nil { http.Error(w, `{"error":"failed to unblock user"}`, http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } type registerRequest struct { Email string `json:"email"` Username string `json:"username"` DisplayName string `json:"display_name"` Password string `json:"password"` } type loginRequest struct { Email string `json:"email"` Username string `json:"username"` Password string `json:"password"` } type updateProfileRequest struct { 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"` } type PublicUserProfile struct { UserProfile BannerURL string `json:"banner_url"` Tagline string `json:"tagline"` Pronouns string `json:"pronouns"` SocialLinks []map[string]interface{} `json:"social_links"` Badges []badgeResponse `json:"badges"` } type badgeResponse struct { ID string `json:"id"` Name string `json:"name"` Icon string `json:"icon"` Description string `json:"description"` ServerID string `json:"server_id,omitempty"` } type blockResponse struct { ID string `json:"id"` Username string `json:"username"` CreatedAt string `json:"created_at"` } type changePasswordRequest struct { CurrentPassword string `json:"current_password"` NewPassword string `json:"new_password"` } type UserProfile struct { ID string `json:"id"` Username string `json:"username"` DisplayName string `json:"display_name"` Email string `json:"email"` Avatar string `json:"avatar"` Bio string `json:"bio"` AccentColor string `json:"accent_color"` Status string `json:"status"` StatusText string `json:"status_text"` CreatedAt string `json:"created_at"` } func gravatarURL(email string) string { email = strings.ToLower(strings.TrimSpace(email)) hash := md5.Sum([]byte(email)) return fmt.Sprintf("https://www.gravatar.com/avatar/%s?d=identicon&s=256", hex.EncodeToString(hash[:])) } // @Summary Register a new user // @Description Create a new user account // @Tags auth // @Accept json // @Produce json // @Param body body registerRequest true "Registration data" // @Success 200 {object} map[string]string // @Failure 400 {object} map[string]string // @Failure 409 {object} map[string]string // @Router /auth/register [post] func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { var req registerRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } if req.Email == "" || req.Username == "" || req.Password == "" { http.Error(w, `{"error":"missing fields"}`, http.StatusBadRequest) return } hash, err := HashPassword(req.Password) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } displayName := req.DisplayName if displayName == "" { displayName = req.Username } var userID string err = h.db.QueryRowContext(r.Context(), ` INSERT INTO users (username, display_name, email, password_hash, avatar) VALUES ($1, $2, $3, $4, $5) RETURNING id `, req.Username, displayName, req.Email, hash, gravatarURL(req.Email)).Scan(&userID) if err != nil { http.Error(w, `{"error":"user exists"}`, http.StatusConflict) return } token, err := h.sessions.Create(r.Context(), userID) if err != nil { http.Error(w, `{"error":"session error"}`, http.StatusInternalServerError) return } h.setSessionCookie(w, token) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"id": userID}) } // @Summary Login with email and password // @Description Authenticate with email and password // @Tags auth // @Accept json // @Produce json // @Param body body loginRequest true "Login credentials" // @Success 200 {object} map[string]string // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Router /auth/login/password [post] func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { var req loginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } login := strings.TrimSpace(req.Email) if login == "" { login = strings.TrimSpace(req.Username) } if login == "" || req.Password == "" { http.Error(w, `{"error":"email/username and password required"}`, http.StatusBadRequest) return } var userID, hash string err := h.db.QueryRowContext(r.Context(), ` SELECT id, password_hash FROM users WHERE LOWER(email) = LOWER($1) OR LOWER(username) = LOWER($1) `, login).Scan(&userID, &hash) if err != nil { http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized) return } ok, err := VerifyPassword(req.Password, hash) if err != nil || !ok { http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized) return } token, err := h.sessions.Create(r.Context(), userID) if err != nil { http.Error(w, `{"error":"session error"}`, http.StatusInternalServerError) return } h.setSessionCookie(w, token) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"id": userID}) } // @Summary Logout // @Description End the current session // @Tags auth // @Success 204 // @Router /auth/logout [post] func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie(h.cfg.Session.CookieName) if err == nil { h.sessions.Delete(r.Context(), cookie.Value) } http.SetCookie(w, &http.Cookie{ Name: h.cfg.Session.CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, }) w.WriteHeader(http.StatusNoContent) } // @Summary Get current user profile // @Description Get the authenticated user's profile // @Tags auth // @Produce json // @Security SessionAuth // @Success 200 {object} UserProfile // @Failure 401 {object} map[string]string // @Router /auth/me [get] func (h *Handler) Me(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":"not found"}`, http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(user) } // @Summary Update current user profile // @Description Update the authenticated user's profile // @Tags auth // @Accept json // @Produce json // @Security SessionAuth // @Param body body updateProfileRequest true "Profile update data" // @Success 200 {object} UserProfile // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Router /auth/me [patch] func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } var req updateProfileRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } if len(req.Bio) > 250 { http.Error(w, `{"error":"bio too long"}`, http.StatusBadRequest) return } if len(req.StatusText) > 128 { http.Error(w, `{"error":"status text too long"}`, http.StatusBadRequest) return } if len(req.DisplayName) > 32 { http.Error(w, `{"error":"display name too long"}`, http.StatusBadRequest) return } if len(req.Tagline) > 128 { http.Error(w, `{"error":"tagline too long"}`, http.StatusBadRequest) return } if len(req.Pronouns) > 40 { http.Error(w, `{"error":"pronouns too long"}`, http.StatusBadRequest) return } if req.AccentColor != "" && len(req.AccentColor) != 7 { http.Error(w, `{"error":"accent color must be 7 char hex"}`, http.StatusBadRequest) return } if req.Status != "" && !isValidStatus(req.Status) { http.Error(w, `{"error":"invalid status"}`, http.StatusBadRequest) return } socialLinksJSON, _ := json.Marshal(req.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 WHERE id = $1 `, userID, req.DisplayName, req.Bio, req.AccentColor, req.StatusText, req.Status, req.BannerURL, req.Tagline, req.Pronouns, socialLinksJSON) if err != nil { http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError) return } if req.Status != "" && 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) } } user, err := h.getProfile(r.Context(), userID) if err != nil { http.Error(w, `{"error":"not found"}`, http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(user) } func isValidStatus(status string) bool { switch status { case "online", "idle", "dnd", "offline": return true } return false } func (h *Handler) getProfile(ctx context.Context, userID string) (UserProfile, error) { var user UserProfile return user, h.db.QueryRowContext(ctx, ` SELECT id, username, display_name, email, avatar, bio, accent_color, status, status_text, created_at FROM users WHERE id = $1 `, userID).Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.Avatar, &user.Bio, &user.AccentColor, &user.Status, &user.StatusText, &user.CreatedAt) } func (h *Handler) setSessionCookie(w http.ResponseWriter, token string) { SetSessionCookie(w, h.cfg.Session.CookieName, token, h.cfg.Session.Duration) } // ChangePassword handles PUT /auth/me/password. func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } var req changePasswordRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } if req.CurrentPassword == "" || req.NewPassword == "" { http.Error(w, `{"error":"current and new password required"}`, http.StatusBadRequest) return } if len(req.NewPassword) < 8 { http.Error(w, `{"error":"password must be at least 8 characters"}`, http.StatusBadRequest) return } // Verify current password var currentHash string err := h.db.QueryRowContext(r.Context(), "SELECT password_hash FROM users WHERE id = $1", userID).Scan(¤tHash) if err != nil { http.Error(w, `{"error":"user not found"}`, http.StatusNotFound) return } verified, _ := VerifyPassword(req.CurrentPassword, currentHash) if !verified { http.Error(w, `{"error":"current password is incorrect"}`, http.StatusUnauthorized) return } // Hash new password newHash, err := HashPassword(req.NewPassword) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } _, err = h.db.ExecContext(r.Context(), "UPDATE users SET password_hash = $1 WHERE id = $2", newHash, userID) if err != nil { http.Error(w, `{"error":"failed to update password"}`, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"message": "password updated"}) }