// Package handlers package handlers import ( "encoding/json" "errors" "net/http" "time" "github.com/photoplaces/backend/internal/middleware" "github.com/photoplaces/backend/internal/services" "github.com/photoplaces/backend/internal/validator" ) type AuthHandler struct { authSvc *services.AuthService isProd bool } func NewAuthHandler(authSvc *services.AuthService, appEnv string) *AuthHandler { return &AuthHandler{ authSvc: authSvc, isProd: appEnv == "production", } } type registerRequest struct { Email string `json:"email" validate:"required,email,max=255"` Password string `json:"password" validate:"required,min=8,max=72"` Role string `json:"role" validate:"omitempty,user_role"` Name string `json:"name" validate:"omitempty,max=255"` } func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { var req registerRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if err := validator.Validate(req); err != nil { writeValidationError(w, err) return } if req.Role == "" { req.Role = "customer" } result, refreshToken, err := h.authSvc.Register(r.Context(), services.RegisterInput{ Email: req.Email, Password: req.Password, Role: req.Role, Name: req.Name, }) if err != nil { if errors.Is(err, services.ErrEmailExists) { writeError(w, http.StatusConflict, "email already exists") return } writeError(w, http.StatusInternalServerError, err.Error()) return } h.setRefreshTokenCookie(w, refreshToken) writeJSON(w, http.StatusCreated, result) } type loginRequest struct { Email string `json:"email" validate:"required,email,max=255"` Password string `json:"password" validate:"required,max=72"` } func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { var req loginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if err := validator.Validate(req); err != nil { writeValidationError(w, err) return } result, refreshToken, err := h.authSvc.Login(r.Context(), services.LoginInput{ Email: req.Email, Password: req.Password, }) if err != nil { if errors.Is(err, services.ErrInvalidCreds) { writeError(w, http.StatusUnauthorized, "invalid email or password") return } if errors.Is(err, services.ErrUserBanned) { writeError(w, http.StatusForbidden, "account is banned") return } writeError(w, http.StatusInternalServerError, err.Error()) return } h.setRefreshTokenCookie(w, refreshToken) writeJSON(w, http.StatusOK, result) } func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) { refreshToken := getRefreshTokenFromCookie(r) if refreshToken == "" { writeError(w, http.StatusBadRequest, "refresh token required") return } result, newRefreshToken, err := h.authSvc.RefreshSession(r.Context(), refreshToken) if err != nil { if errors.Is(err, services.ErrTokenReused) { h.clearRefreshTokenCookie(w) writeError(w, http.StatusUnauthorized, "token reused, session revoked") return } h.clearRefreshTokenCookie(w) writeError(w, http.StatusUnauthorized, "invalid or expired refresh token") return } h.setRefreshTokenCookie(w, newRefreshToken) writeJSON(w, http.StatusOK, result) } func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { refreshToken := getRefreshTokenFromCookie(r) if refreshToken != "" { _ = h.authSvc.RevokeSession(r.Context(), refreshToken) } h.clearRefreshTokenCookie(w) w.WriteHeader(http.StatusNoContent) } func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) if userID == "" { writeError(w, http.StatusUnauthorized, "not authenticated") return } writeJSON(w, http.StatusOK, map[string]string{"user_id": userID, "role": middleware.GetUserRole(r.Context())}) } const refreshTokenCookieName = "refresh_token" func (h *AuthHandler) setRefreshTokenCookie(w http.ResponseWriter, token string) { cookie := &http.Cookie{ Name: refreshTokenCookieName, Value: token, Path: "/", HttpOnly: true, Secure: h.isProd, SameSite: http.SameSiteLaxMode, MaxAge: int((30 * 24 * time.Hour).Seconds()), } http.SetCookie(w, cookie) } func getRefreshTokenFromCookie(r *http.Request) string { cookie, err := r.Cookie(refreshTokenCookieName) if err != nil { return "" } return cookie.Value } func (h *AuthHandler) clearRefreshTokenCookie(w http.ResponseWriter) { cookie := &http.Cookie{ Name: refreshTokenCookieName, Value: "", Path: "/", HttpOnly: true, Secure: h.isProd, SameSite: http.SameSiteLaxMode, MaxAge: -1, } http.SetCookie(w, cookie) } func writeJSON(w http.ResponseWriter, status int, v interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } func writeValidationError(w http.ResponseWriter, err error) { var ve validator.ValidationErrors if errors.As(err, &ve) { details := make(map[string]string) for _, fe := range ve { details[fe.Field()] = validationMessage(fe.Tag(), fe.Param()) } writeJSON(w, http.StatusUnprocessableEntity, map[string]interface{}{ "error": "validation failed", "details": details, }) return } writeError(w, http.StatusBadRequest, err.Error()) } func validationMessage(tag, param string) string { switch tag { case "required": return "field is required" case "email": return "invalid email format" case "min": return "value too short (min " + param + ")" case "max": return "value too long (max " + param + ")" case "user_role": return "invalid role (customer, landlord, executor, moderator, superadmin)" case "latitude": return "latitude must be between -90 and 90" case "longitude": return "longitude must be between -180 and 180" case "place_type": return "type must be 'place' or 'studio'" case "currency": return "currency must be 3-letter ISO code" case "uuid": return "invalid UUID format" case "slug": return "invalid slug format (lowercase, numbers, hyphens only)" default: return "validation failed: " + tag } }