583 lines
17 KiB
Go
583 lines
17 KiB
Go
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"})
|
|
}
|