Harden API security: input validation, safe auth extraction, new tests, and deploy config
Comprehensive security hardening from audit findings: - Add validation tags to all DTO request structs (max lengths, ranges, enums) - Replace unsafe type assertions with MustGetAuthUser helper across all handlers - Remove query-param token auth from admin middleware (prevents URL token leakage) - Add request validation calls in handlers that were missing c.Validate() - Remove goroutines in handlers (timezone update now synchronous) - Add sanitize middleware and path traversal protection (path_utils) - Stop resetting admin passwords on migration restart - Warn on well-known default SECRET_KEY - Add ~30 new test files covering security regressions, auth safety, repos, and services - Add deploy/ config, audit digests, and AUDIT_FINDINGS documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -560,11 +560,7 @@ func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest
|
||||
Rating: req.Rating,
|
||||
}
|
||||
|
||||
if err := s.taskRepo.CreateCompletion(completion); err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Update next_due_date and in_progress based on frequency
|
||||
// Determine interval days for NextDueDate calculation before entering the transaction.
|
||||
// - If frequency is "Once" (days = nil or 0), set next_due_date to nil (marks as completed)
|
||||
// - If frequency is "Custom", use task.CustomIntervalDays for recurrence
|
||||
// - If frequency is recurring, calculate next_due_date = completion_date + frequency_days
|
||||
@@ -598,11 +594,25 @@ func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest
|
||||
// instead of staying in "In Progress" column
|
||||
task.InProgress = false
|
||||
}
|
||||
if err := s.taskRepo.Update(task); err != nil {
|
||||
if errors.Is(err, repositories.ErrVersionConflict) {
|
||||
|
||||
// P1-5: Wrap completion creation and task update in a transaction.
|
||||
// If either operation fails, both are rolled back to prevent orphaned completions.
|
||||
txErr := s.taskRepo.DB().Transaction(func(tx *gorm.DB) error {
|
||||
if err := s.taskRepo.CreateCompletionTx(tx, completion); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.taskRepo.UpdateTx(tx, task); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if txErr != nil {
|
||||
// P1-6: Return the error instead of swallowing it.
|
||||
if errors.Is(txErr, repositories.ErrVersionConflict) {
|
||||
return nil, apperrors.Conflict("error.version_conflict")
|
||||
}
|
||||
log.Error().Err(err).Uint("task_id", task.ID).Msg("Failed to update task after completion")
|
||||
log.Error().Err(txErr).Uint("task_id", task.ID).Msg("Failed to create completion and update task")
|
||||
return nil, apperrors.Internal(txErr)
|
||||
}
|
||||
|
||||
// Create images if provided
|
||||
@@ -731,8 +741,15 @@ func (s *TaskService) QuickComplete(taskID uint, userID uint) error {
|
||||
}
|
||||
log.Info().Uint("task_id", task.ID).Msg("QuickComplete: Task updated successfully")
|
||||
|
||||
// Send notification (fire and forget)
|
||||
go s.sendTaskCompletedNotification(task, completion)
|
||||
// Send notification (fire and forget with panic recovery)
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Error().Interface("panic", r).Uint("task_id", task.ID).Msg("Panic in quick-complete notification goroutine")
|
||||
}
|
||||
}()
|
||||
s.sendTaskCompletedNotification(task, completion)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -764,23 +781,23 @@ func (s *TaskService) sendTaskCompletedNotification(task *models.Task, completio
|
||||
emailImages = s.loadCompletionImagesForEmail(completion.Images)
|
||||
}
|
||||
|
||||
// Notify all users
|
||||
// Notify all users synchronously to avoid unbounded goroutine spawning.
|
||||
// This method is already called from a goroutine (QuickComplete) or inline
|
||||
// (CreateCompletion) where blocking is acceptable for notification delivery.
|
||||
for _, user := range users {
|
||||
isCompleter := user.ID == completion.CompletedByID
|
||||
|
||||
// Send push notification (to everyone EXCEPT the person who completed it)
|
||||
if !isCompleter && s.notificationService != nil {
|
||||
go func(userID uint) {
|
||||
ctx := context.Background()
|
||||
if err := s.notificationService.CreateAndSendTaskNotification(
|
||||
ctx,
|
||||
userID,
|
||||
models.NotificationTaskCompleted,
|
||||
task,
|
||||
); err != nil {
|
||||
log.Error().Err(err).Uint("user_id", userID).Uint("task_id", task.ID).Msg("Failed to send task completion push notification")
|
||||
}
|
||||
}(user.ID)
|
||||
ctx := context.Background()
|
||||
if err := s.notificationService.CreateAndSendTaskNotification(
|
||||
ctx,
|
||||
user.ID,
|
||||
models.NotificationTaskCompleted,
|
||||
task,
|
||||
); err != nil {
|
||||
log.Error().Err(err).Uint("user_id", user.ID).Uint("task_id", task.ID).Msg("Failed to send task completion push notification")
|
||||
}
|
||||
}
|
||||
|
||||
// Send email notification (to everyone INCLUDING the person who completed it)
|
||||
@@ -789,20 +806,18 @@ func (s *TaskService) sendTaskCompletedNotification(task *models.Task, completio
|
||||
prefs, err := s.notificationService.GetPreferences(user.ID)
|
||||
if err != nil || (prefs != nil && prefs.EmailTaskCompleted) {
|
||||
// Send email if we couldn't get prefs (fail-open) or if email notifications are enabled
|
||||
go func(u models.User, images []EmbeddedImage) {
|
||||
if err := s.emailService.SendTaskCompletedEmail(
|
||||
u.Email,
|
||||
u.GetFullName(),
|
||||
task.Title,
|
||||
completedByName,
|
||||
residenceName,
|
||||
images,
|
||||
); err != nil {
|
||||
log.Error().Err(err).Str("email", u.Email).Uint("task_id", task.ID).Msg("Failed to send task completion email")
|
||||
} else {
|
||||
log.Info().Str("email", u.Email).Uint("task_id", task.ID).Int("images", len(images)).Msg("Task completion email sent")
|
||||
}
|
||||
}(user, emailImages)
|
||||
if err := s.emailService.SendTaskCompletedEmail(
|
||||
user.Email,
|
||||
user.GetFullName(),
|
||||
task.Title,
|
||||
completedByName,
|
||||
residenceName,
|
||||
emailImages,
|
||||
); err != nil {
|
||||
log.Error().Err(err).Str("email", user.Email).Uint("task_id", task.ID).Msg("Failed to send task completion email")
|
||||
} else {
|
||||
log.Info().Str("email", user.Email).Uint("task_id", task.ID).Int("images", len(emailImages)).Msg("Task completion email sent")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -846,20 +861,28 @@ func (s *TaskService) loadCompletionImagesForEmail(images []models.TaskCompletio
|
||||
return emailImages
|
||||
}
|
||||
|
||||
// resolveImageFilePath converts a stored URL to an actual file path
|
||||
// resolveImageFilePath converts a stored URL to an actual file path.
|
||||
// Returns empty string if the URL is empty or the resolved path would escape
|
||||
// the upload directory (path traversal attempt).
|
||||
func (s *TaskService) resolveImageFilePath(storedURL, uploadDir string) string {
|
||||
if storedURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Handle /uploads/... URLs
|
||||
// Strip legacy /uploads/ prefix to get relative path
|
||||
relativePath := storedURL
|
||||
if strings.HasPrefix(storedURL, "/uploads/") {
|
||||
relativePath := strings.TrimPrefix(storedURL, "/uploads/")
|
||||
return filepath.Join(uploadDir, relativePath)
|
||||
relativePath = strings.TrimPrefix(storedURL, "/uploads/")
|
||||
}
|
||||
|
||||
// Handle relative paths
|
||||
return filepath.Join(uploadDir, storedURL)
|
||||
// Use SafeResolvePath to validate containment within upload directory
|
||||
resolved, err := SafeResolvePath(uploadDir, relativePath)
|
||||
if err != nil {
|
||||
// Path traversal or invalid path — return empty to signal file not found
|
||||
return ""
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
// getContentTypeFromPath returns the MIME type based on file extension
|
||||
@@ -977,7 +1000,11 @@ func (s *TaskService) UpdateCompletion(completionID, userID uint, req *requests.
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// DeleteCompletion deletes a task completion
|
||||
// DeleteCompletion deletes a task completion and recalculates the task's NextDueDate.
|
||||
//
|
||||
// P1-7: After deleting a completion, NextDueDate must be recalculated:
|
||||
// - If no completions remain: restore NextDueDate = DueDate (original schedule)
|
||||
// - If completions remain (recurring): recalculate from latest remaining completion + frequency days
|
||||
func (s *TaskService) DeleteCompletion(completionID, userID uint) (*responses.DeleteWithSummaryResponse, error) {
|
||||
completion, err := s.taskRepo.FindCompletionByID(completionID)
|
||||
if err != nil {
|
||||
@@ -996,10 +1023,66 @@ func (s *TaskService) DeleteCompletion(completionID, userID uint) (*responses.De
|
||||
return nil, apperrors.Forbidden("error.task_access_denied")
|
||||
}
|
||||
|
||||
taskID := completion.TaskID
|
||||
|
||||
if err := s.taskRepo.DeleteCompletion(completionID); err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Recalculate NextDueDate based on remaining completions
|
||||
task, err := s.taskRepo.FindByID(taskID)
|
||||
if err != nil {
|
||||
// Non-fatal for the delete operation itself, but log the error
|
||||
log.Error().Err(err).Uint("task_id", taskID).Msg("Failed to reload task after completion deletion for NextDueDate recalculation")
|
||||
return &responses.DeleteWithSummaryResponse{
|
||||
Data: "completion deleted",
|
||||
Summary: s.getSummaryForUser(userID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Get remaining completions for this task
|
||||
remainingCompletions, err := s.taskRepo.FindCompletionsByTask(taskID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Uint("task_id", taskID).Msg("Failed to query remaining completions after deletion")
|
||||
return &responses.DeleteWithSummaryResponse{
|
||||
Data: "completion deleted",
|
||||
Summary: s.getSummaryForUser(userID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Determine the task's frequency interval
|
||||
var intervalDays *int
|
||||
if task.FrequencyID != nil {
|
||||
frequency, freqErr := s.taskRepo.GetFrequencyByID(*task.FrequencyID)
|
||||
if freqErr == nil && frequency != nil {
|
||||
if frequency.Name == "Custom" {
|
||||
intervalDays = task.CustomIntervalDays
|
||||
} else {
|
||||
intervalDays = frequency.Days
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(remainingCompletions) == 0 {
|
||||
// No completions remain: restore NextDueDate to the original DueDate
|
||||
task.NextDueDate = task.DueDate
|
||||
} else if intervalDays != nil && *intervalDays > 0 {
|
||||
// Recurring task with remaining completions: recalculate from the latest completion
|
||||
// remainingCompletions is ordered by completed_at DESC, so index 0 is the latest
|
||||
latestCompletion := remainingCompletions[0]
|
||||
nextDue := latestCompletion.CompletedAt.AddDate(0, 0, *intervalDays)
|
||||
task.NextDueDate = &nextDue
|
||||
} else {
|
||||
// One-time task with remaining completions (unusual case): keep NextDueDate as nil
|
||||
// since the task is still considered completed
|
||||
task.NextDueDate = nil
|
||||
}
|
||||
|
||||
if err := s.taskRepo.Update(task); err != nil {
|
||||
log.Error().Err(err).Uint("task_id", taskID).Msg("Failed to update task NextDueDate after completion deletion")
|
||||
// The completion was already deleted; return success but log the update failure
|
||||
}
|
||||
|
||||
return &responses.DeleteWithSummaryResponse{
|
||||
Data: "completion deleted",
|
||||
Summary: s.getSummaryForUser(userID),
|
||||
|
||||
Reference in New Issue
Block a user