package handlers import ( "net/http" "github.com/labstack/echo/v4" "github.com/rs/zerolog/log" "github.com/treytartt/honeydue-api/internal/apperrors" "github.com/treytartt/honeydue-api/internal/dto/requests" "github.com/treytartt/honeydue-api/internal/dto/responses" "github.com/treytartt/honeydue-api/internal/middleware" "github.com/treytartt/honeydue-api/internal/services" "github.com/treytartt/honeydue-api/internal/validator" ) // AuthHandler handles user profile and account management endpoints. // Session lifecycle (login, register, logout, password reset) is delegated // to Ory Kratos; this handler only deals with the honeyDue user record. type AuthHandler struct { authService *services.AuthService emailService *services.EmailService cache *services.CacheService storageService *services.StorageService auditService *services.AuditService } // NewAuthHandler creates a new auth handler. func NewAuthHandler(authService *services.AuthService, emailService *services.EmailService, cache *services.CacheService) *AuthHandler { return &AuthHandler{ authService: authService, emailService: emailService, cache: cache, } } // SetStorageService sets the storage service for file deletion during account deletion. func (h *AuthHandler) SetStorageService(storageService *services.StorageService) { h.storageService = storageService } // SetAuditService sets the audit service for logging security events. func (h *AuthHandler) SetAuditService(auditService *services.AuditService) { h.auditService = auditService } // noStore marks a response as non-cacheable. func noStore(c echo.Context) { c.Response().Header().Set("Cache-Control", "no-store") } // Register handles POST /api/auth/register/ — creates a new password account. // // The identity is admin-created in Kratos with an unverified email and no // auto-sent code (see services.AuthService.Register). The client logs in right // after to get a session, then completes email verification. Returns 201 with // no token; 409 if the email is taken; 400 on a weak password. func (h *AuthHandler) Register(c echo.Context) error { var req requests.RegisterRequest if err := c.Bind(&req); err != nil { return apperrors.BadRequest("error.invalid_request_body") } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err)) } if err := h.authService.Register(c.Request().Context(), &req); err != nil { return err } return c.JSON(http.StatusCreated, map[string]string{ "message": "Account created. Please verify your email.", }) } // CurrentUser handles GET /api/auth/me/ func (h *AuthHandler) CurrentUser(c echo.Context) error { noStore(c) user, err := middleware.MustGetAuthUser(c) if err != nil { return err } response, err := h.authService.GetCurrentUser(c.Request().Context(), user.ID) if err != nil { log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to get current user") return err } // user_profile.verified is a one-time mirror set at provision time // (see middleware/kratos_auth.go::provision). Kratos remains the source // of truth for email-verification state — it can flip from false → true // the instant the user completes the verification flow, and nothing // updates the local column. Override the response with the live value // the Kratos auth middleware already stashed in context so /auth/me // reflects current reality. Also opportunistically sync the DB mirror // (best-effort, ignore error) so background queries that read the // column see the same answer. if verified, ok := c.Get(middleware.AuthVerifiedKey).(bool); ok { mirrorStale := response.Profile != nil && response.Profile.Verified != verified if response.Profile != nil { response.Profile.Verified = verified } if verified && mirrorStale { _ = h.authService.MarkUserVerified(c.Request().Context(), user.ID) } } return c.JSON(http.StatusOK, response) } // UpdateProfile handles PUT/PATCH /api/auth/profile/ func (h *AuthHandler) UpdateProfile(c echo.Context) error { user, err := middleware.MustGetAuthUser(c) if err != nil { return err } var req requests.UpdateProfileRequest if err := c.Bind(&req); err != nil { return apperrors.BadRequest("error.invalid_request") } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err)) } response, err := h.authService.UpdateProfile(c.Request().Context(), user.ID, &req) if err != nil { log.Debug().Err(err).Uint("user_id", user.ID).Msg("Failed to update profile") return err } return c.JSON(http.StatusOK, response) } // DeleteAccount handles DELETE /api/auth/account/ func (h *AuthHandler) DeleteAccount(c echo.Context) error { user, err := middleware.MustGetAuthUser(c) if err != nil { return err } var req requests.DeleteAccountRequest if err := c.Bind(&req); err != nil { return apperrors.BadRequest("error.invalid_request") } fileURLs, err := h.authService.DeleteAccount(c.Request().Context(), user.ID, req.Password, req.Confirmation) if err != nil { log.Debug().Err(err).Uint("user_id", user.ID).Msg("Account deletion failed") return err } if h.auditService != nil { h.auditService.LogEvent(c, &user.ID, services.AuditEventAccountDeleted, map[string]interface{}{ "user_id": user.ID, "username": user.Username, "email": user.Email, }) } // Delete files from disk (best effort, don't fail the request) if h.storageService != nil && len(fileURLs) > 0 { go func() { defer func() { if r := recover(); r != nil { log.Error().Interface("panic", r).Uint("user_id", user.ID).Msg("Panic in file cleanup goroutine") } }() for _, fileURL := range fileURLs { if err := h.storageService.Delete(fileURL); err != nil { log.Warn().Err(err).Str("file_url", fileURL).Msg("Failed to delete file during account cleanup") } } }() } return c.JSON(http.StatusOK, responses.MessageResponse{Message: "Account deleted successfully"}) }