b54493f785
BE-3 observability: expose the worker's Prometheus metrics on :6060/metrics (apns/fcm/asynq histograms + a new cache_ops_total counter were recorded all along but never scraped — which is why those dashboard panels read empty); add the worker containerPort, the vmagent worker scrape job, and two additive NetworkPolicies. Instrument cache Get/Set hit/miss. BE-2 retention: three periodic Asynq cleanup crons mirroring the reminder-log cleanup — notifications (90d), webhook dedup log (180d), audit_log (365d). BE-1 GDPR data export: POST /api/auth/export/ enqueues a low-priority Asynq job that gathers all of the user's data (owned residences + their tasks/contractors/ documents/share-codes, plus profile/notifications/prefs/push-tokens/subscription/ audit log), zips one JSON file per category, and emails it as an attachment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
212 lines
7.2 KiB
Go
212 lines
7.2 KiB
Go
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"
|
|
"github.com/treytartt/honeydue-api/internal/worker"
|
|
)
|
|
|
|
// 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
|
|
enqueuer worker.Enqueuer
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// SetEnqueuer sets the async task enqueuer (used by the GDPR data-export endpoint).
|
|
func (h *AuthHandler) SetEnqueuer(enqueuer worker.Enqueuer) {
|
|
h.enqueuer = enqueuer
|
|
}
|
|
|
|
// ExportData handles POST /api/auth/export/ — queues a GDPR data-export job that
|
|
// emails the user a zip of all their data. Async (202) because gathering,
|
|
// zipping, and emailing can take seconds; doing it inline would block the request.
|
|
func (h *AuthHandler) ExportData(c echo.Context) error {
|
|
noStore(c)
|
|
user, err := middleware.MustGetAuthUser(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if h.enqueuer == nil {
|
|
return echo.NewHTTPError(http.StatusServiceUnavailable, "data export is temporarily unavailable")
|
|
}
|
|
if err := h.enqueuer.EnqueueDataExport(user.ID); err != nil {
|
|
log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to enqueue data export")
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to queue data export")
|
|
}
|
|
if h.auditService != nil {
|
|
h.auditService.LogEvent(c, &user.ID, services.AuditEventDataExport, map[string]interface{}{
|
|
"user_id": user.ID,
|
|
"email": user.Email,
|
|
})
|
|
}
|
|
return c.JSON(http.StatusAccepted, map[string]string{
|
|
"message": "Your data export has been queued. You'll receive an email with your data shortly.",
|
|
})
|
|
}
|
|
|
|
// 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"})
|
|
}
|