diff --git a/backend/go.mod b/backend/go.mod index 8f12b127..6da54e36 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -24,8 +24,8 @@ require ( github.com/go-webauthn/webauthn v0.17.4 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 - github.com/italypaleale/francis v0.1.0-beta.22 - github.com/italypaleale/go-kit v0.0.0-20260806152440-491aff6b42b5 + github.com/italypaleale/francis v0.1.0-beta.23 + github.com/italypaleale/go-kit v0.0.0-20260810215935-944b377ddc2f github.com/italypaleale/go-sql-utils v0.3.5 github.com/jackc/pgx/v5 v5.10.0 github.com/jinzhu/copier v0.4.0 diff --git a/backend/go.sum b/backend/go.sum index 332aea4b..1476b5c1 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -243,14 +243,10 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/italypaleale/francis v0.1.0-beta.21 h1:pYfPQVR9TIHihigrCmUiRYwHDfUoFHGoVQl5MUmysvg= -github.com/italypaleale/francis v0.1.0-beta.21/go.mod h1:Ii6GFfm7AOt8kwWVsza2Tlq2PX17ofq/vJJaNsAoS3U= -github.com/italypaleale/francis v0.1.0-beta.22 h1:ONOCfPEgvjy6fraAyKqX+wobTXIMZperWRruhj27jxQ= -github.com/italypaleale/francis v0.1.0-beta.22/go.mod h1:qB+0OgLCTWw3/AmdHe30bN81TTo5BcI95kAwAJdLPuM= -github.com/italypaleale/go-kit v0.0.0-20260806152440-491aff6b42b5 h1:bT2aB2nHewW4pHmVk36aRADW1owTDz1q1V5mRa5BCs0= -github.com/italypaleale/go-kit v0.0.0-20260806152440-491aff6b42b5/go.mod h1:wg4UsIbsbtDiVqUjJdo/tO9lXo0OXBuwPOpSJ+u+1jI= -github.com/italypaleale/go-sql-utils v0.3.4 h1:g9LGdXHhUKMC/3eufRYyY9mN9Vvi4PnLV+OXJwBKGt4= -github.com/italypaleale/go-sql-utils v0.3.4/go.mod h1:STWS4qGjiGKt2tf9OdRUlf025FhKZvUA31siUOnejj0= +github.com/italypaleale/francis v0.1.0-beta.23 h1:Yu0K/LE7NwQWVC1yOH6/uVE/+1JZ966aAftPnDTUDmw= +github.com/italypaleale/francis v0.1.0-beta.23/go.mod h1:qB+0OgLCTWw3/AmdHe30bN81TTo5BcI95kAwAJdLPuM= +github.com/italypaleale/go-kit v0.0.0-20260810215935-944b377ddc2f h1:0WjKnQPDkpvDcOIDT0Xd1LtXhnn59agn5vVmN/ItD+g= +github.com/italypaleale/go-kit v0.0.0-20260810215935-944b377ddc2f/go.mod h1:wg4UsIbsbtDiVqUjJdo/tO9lXo0OXBuwPOpSJ+u+1jI= github.com/italypaleale/go-sql-utils v0.3.5 h1:kkrhIo1tVJvcsjRc1kePWE9Cqdm26+Et5XZYSNmv764= github.com/italypaleale/go-sql-utils v0.3.5/go.mod h1:STWS4qGjiGKt2tf9OdRUlf025FhKZvUA31siUOnejj0= github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ= diff --git a/backend/internal/apikey/expiry_job.go b/backend/internal/apikey/expiry_job.go new file mode 100644 index 00000000..e53020be --- /dev/null +++ b/backend/internal/apikey/expiry_job.go @@ -0,0 +1,101 @@ +package apikey + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/italypaleale/francis/builtin/cronjob" + + "github.com/pocket-id/pocket-id/backend/internal/appconfig" +) + +type APIKeyExpiryEmailSender interface { + SendAPIKeyExpiringSoon(ctx context.Context, dbConfig *appconfig.AppConfigModel, userFullName, userEmail, firstName, apiKeyName string, expiresAt time.Time) error +} + +type expiryJob struct { + service *Service + appConfig appconfig.AppConfigResolver + email APIKeyExpiryEmailSender +} + +func newExpiryJob(service *Service, appConfig appconfig.AppConfigResolver, email APIKeyExpiryEmailSender) (*cronjob.CronJob, error) { + job := &expiryJob{ + service: service, + appConfig: appConfig, + email: email, + } + + cronActor, err := cronjob.New( + "ExpiredApiKeyEmailJob", + cronjob.WithJob(job.checkAndNotifyExpiringAPIKeys), + // Run at midnight (± 2 minutes) + // We want a consistent time because we are emailing users + cronjob.WithCron("0 0 * * *"), + cronjob.WithLogger(slog.Default()), + cronjob.WithJitter(2*time.Minute), + ) + if err != nil { + return nil, fmt.Errorf("error creating API key expiration cron job: %w", err) + } + + return cronActor, nil +} + +func (j *expiryJob) checkAndNotifyExpiringAPIKeys(ctx context.Context) error { + dbConfig, err := j.appConfig.GetConfig(ctx) + if err != nil { + return fmt.Errorf("error loading app config: %w", err) + } + + // Skip the database query when expiration notifications are disabled + if !dbConfig.EmailApiKeyExpirationEnabled.IsTrue() { + return nil + } + + // Load API keys that entered the notification window since the last occurrence + apiKeys, err := j.service.ListExpiringApiKeys(ctx, 7) + if err != nil { + return fmt.Errorf("failed to list expiring API keys: %w", err) + } + + for _, key := range apiKeys { + if key.User.Email == nil { + continue + } + + // Continue processing other recipients when one delivery fails + err = j.email.SendAPIKeyExpiringSoon( + ctx, + dbConfig, + key.User.FullName(), + *key.User.Email, + key.User.FirstName, + key.Name, + key.ExpiresAt.ToTime(), + ) + if err != nil { + slog.ErrorContext(ctx, + "Failed to send expiring API key notification email", + slog.String("key", key.ID), + slog.String("user", key.User.ID), + slog.Any("error", err), + ) + continue + } + + // Mark successful deliveries so future cron occurrences do not resend them + err = j.service.MarkExpirationEmailSent(ctx, key.ID) + if err != nil { + slog.ErrorContext(ctx, + "Failed to record that the expiration email was sent", + slog.String("key", key.ID), + slog.Any("error", err), + ) + } + } + + return nil +} diff --git a/backend/internal/apikey/expiry_job_test.go b/backend/internal/apikey/expiry_job_test.go new file mode 100644 index 00000000..d8fd4124 --- /dev/null +++ b/backend/internal/apikey/expiry_job_test.go @@ -0,0 +1,101 @@ +package apikey + +import ( + "context" + "testing" + "time" + + "github.com/italypaleale/francis/host/local" + "github.com/stretchr/testify/require" + + "github.com/pocket-id/pocket-id/backend/internal/appconfig" + "github.com/pocket-id/pocket-id/backend/internal/model" + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" +) + +type expiryJobTestConfig struct { + config *appconfig.AppConfigModel +} + +func (c expiryJobTestConfig) GetConfig(context.Context) (*appconfig.AppConfigModel, error) { + return c.config, nil +} + +type expiryJobTestSender struct { + keyNames []string +} + +func (s *expiryJobTestSender) SendAPIKeyExpiringSoon(_ context.Context, _ *appconfig.AppConfigModel, _, _, _, apiKeyName string, _ time.Time) error { + s.keyNames = append(s.keyNames, apiKeyName) + return nil +} + +func TestModuleRegistersAPIKeyExpiryCronJob(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + config := expiryJobTestConfig{config: &appconfig.AppConfigModel{}} + sender := &expiryJobTestSender{} + + testutils.NewActorHostForTest(t, func(t *testing.T, host *local.Host) { + t.Helper() + _, err := New(t.Context(), Dependencies{ + DB: db, + Actors: host, + AppConfig: config, + EmailSender: sender, + }) + require.NoError(t, err) + }) +} + +func TestAPIKeyExpiryCronJobNotifiesAndMarksExpiringKeys(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + user := model.User{ + Username: "expiry-job-user", + Email: new("expiry-job@example.com"), + FirstName: "Expiry", + LastName: "Job", + DisplayName: "Expiry Job", + } + require.NoError(t, db.Create(&user).Error) + + now := time.Now() + expiringKey := ApiKey{ + Name: "Expiring", + Key: "expiring-hash", + ExpiresAt: datatype.DateTime(now.Add(3 * 24 * time.Hour)), + UserID: user.ID, + } + laterKey := ApiKey{ + Name: "Later", + Key: "later-hash", + ExpiresAt: datatype.DateTime(now.Add(30 * 24 * time.Hour)), + UserID: user.ID, + } + require.NoError(t, db.Create(&expiringKey).Error) + require.NoError(t, db.Create(&laterKey).Error) + + service, err := newService(t.Context(), db, "") + require.NoError(t, err) + config := expiryJobTestConfig{config: &appconfig.AppConfigModel{ + EmailApiKeyExpirationEnabled: "true", + }} + sender := &expiryJobTestSender{} + + cronActor, err := newExpiryJob(service, config, sender) + require.NoError(t, err) + require.Equal(t, "cronjob.ExpiredApiKeyEmailJob", cronActor.ActorType()) + + job := &expiryJob{ + service: service, + appConfig: config, + email: sender, + } + require.NoError(t, job.checkAndNotifyExpiringAPIKeys(t.Context())) + require.Equal(t, []string{"Expiring"}, sender.keyNames) + + require.NoError(t, db.First(&expiringKey, "id = ?", expiringKey.ID).Error) + require.True(t, expiringKey.ExpirationEmailSent) + require.NoError(t, db.First(&laterKey, "id = ?", laterKey.ID).Error) + require.False(t, laterKey.ExpirationEmailSent) +} diff --git a/backend/internal/apikey/handler.go b/backend/internal/apikey/handler.go index 347670d2..6272a1f2 100644 --- a/backend/internal/apikey/handler.go +++ b/backend/internal/apikey/handler.go @@ -39,7 +39,8 @@ func (h *handler) list(c *gin.Context) error { } var apiKeysDto []apiKeyDto - if err := dto.MapStructList(apiKeys, &apiKeysDto); err != nil { + err = dto.MapStructList(apiKeys, &apiKeysDto) + if err != nil { return err } @@ -61,7 +62,8 @@ func (h *handler) create(c *gin.Context) error { userID := c.GetString("userID") var input apiKeyCreateDto - if err := httpserver.BindJSON(c, &input); err != nil { + err := httpserver.BindJSON(c, &input) + if err != nil { return err } @@ -71,7 +73,8 @@ func (h *handler) create(c *gin.Context) error { } var responseDto apiKeyDto - if err := dto.MapStruct(apiKey, &responseDto); err != nil { + err = dto.MapStruct(apiKey, &responseDto) + if err != nil { return err } @@ -94,7 +97,8 @@ func (h *handler) renew(c *gin.Context) error { apiKeyID := c.Param("id") var input apiKeyRenewDto - if err := httpserver.BindJSON(c, &input); err != nil { + err := httpserver.BindJSON(c, &input) + if err != nil { return err } @@ -104,7 +108,8 @@ func (h *handler) renew(c *gin.Context) error { } var responseDto apiKeyDto - if err := dto.MapStruct(apiKey, &responseDto); err != nil { + err = dto.MapStruct(apiKey, &responseDto) + if err != nil { return err } @@ -126,7 +131,8 @@ func (h *handler) revoke(c *gin.Context) error { userID := c.GetString("userID") apiKeyID := c.Param("id") - if err := h.service.RevokeApiKey(c.Request.Context(), userID, apiKeyID); err != nil { + err := h.service.RevokeApiKey(c.Request.Context(), userID, apiKeyID) + if err != nil { return err } diff --git a/backend/internal/apikey/module.go b/backend/internal/apikey/module.go index 87e0c2d0..d0033488 100644 --- a/backend/internal/apikey/module.go +++ b/backend/internal/apikey/module.go @@ -2,17 +2,25 @@ package apikey import ( "context" + "errors" + "fmt" "github.com/gin-gonic/gin" + "github.com/italypaleale/francis/host/local" "gorm.io/gorm" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/httpserver" "github.com/pocket-id/pocket-id/backend/internal/model" ) type Dependencies struct { - DB *gorm.DB - StaticApiKey string + DB *gorm.DB + Actors *local.Host + StaticApiKey string + AppConfig appconfig.AppConfigResolver + EmailSender APIKeyExpiryEmailSender + CleanupDisabled bool } type Module struct { @@ -26,10 +34,32 @@ func New(ctx context.Context, deps Dependencies) (*Module, error) { return nil, err } - return &Module{ + module := &Module{ service: service, handler: newHandler(service), - }, nil + } + + // Register the cleanup job for expired API keys + if !deps.CleanupDisabled { + if deps.Actors == nil { + return nil, errors.New("actor host is required for the API key expiration cron job") + } + if deps.AppConfig == nil || deps.EmailSender == nil { + return nil, errors.New("notification dependencies are required for the API key expiration cron job") + } + + expiryJob, err := newExpiryJob(service, deps.AppConfig, deps.EmailSender) + if err != nil { + return nil, err + } + + err = deps.Actors.RegisterBuiltInActor(expiryJob) + if err != nil { + return nil, fmt.Errorf("error registering API key expiration cron actor: %w", err) + } + } + + return module, nil } // RegisterRoutes mounts the API key management endpoints @@ -47,13 +77,3 @@ func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, auth, authWithoutApiK func (m *Module) ValidateApiKey(ctx context.Context, apiKey string) (model.User, error) { return m.service.ValidateApiKey(ctx, apiKey) } - -// ListExpiringApiKeys returns API keys expiring within the given number of days that have not been notified yet -func (m *Module) ListExpiringApiKeys(ctx context.Context, daysAhead int) ([]ApiKey, error) { - return m.service.ListExpiringApiKeys(ctx, daysAhead) -} - -// MarkExpirationEmailSent records that the expiration notification email was sent for the given API key -func (m *Module) MarkExpirationEmailSent(ctx context.Context, apiKeyID string) error { - return m.service.MarkExpirationEmailSent(ctx, apiKeyID) -} diff --git a/backend/internal/apikey/service.go b/backend/internal/apikey/service.go index 8efed02e..e44d857f 100644 --- a/backend/internal/apikey/service.go +++ b/backend/internal/apikey/service.go @@ -3,6 +3,7 @@ package apikey import ( "context" "errors" + "fmt" "time" "gorm.io/gorm" @@ -46,7 +47,7 @@ func (s *Service) ListApiKeys(ctx context.Context, userID string, listRequestOpt var apiKeys []ApiKey pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &apiKeys) if err != nil { - return nil, utils.PaginationResponse{}, err + return nil, utils.PaginationResponse{}, fmt.Errorf("error listing API keys: %w", err) } return apiKeys, pagination, nil @@ -61,7 +62,7 @@ func (s *Service) CreateApiKey(ctx context.Context, userID string, input apiKeyC // Generate a secure random API key token, err := utils.GenerateRandomAlphanumericString(32) if err != nil { - return ApiKey{}, "", err + return ApiKey{}, "", fmt.Errorf("error generating API key token: %w", err) } apiKey := ApiKey{ @@ -80,7 +81,7 @@ func (s *Service) CreateApiKey(ctx context.Context, userID string, input apiKeyC if errors.Is(err, gorm.ErrDuplicatedKey) { return ApiKey{}, "", apperror.AlreadyInUse("API key name") } - return ApiKey{}, "", err + return ApiKey{}, "", fmt.Errorf("error creating API key: %w", err) } // Return the raw token only once - it cannot be retrieved later @@ -103,12 +104,10 @@ func (s *Service) RenewApiKey(ctx context.Context, userID, apiKeyID string, expi Where("id = ? AND user_id = ?", apiKeyID, userID). First(&apiKey). Error - - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ApiKey{}, "", apperror.APIKeyNotFound() - } - return ApiKey{}, "", err + if errors.Is(err, gorm.ErrRecordNotFound) { + return ApiKey{}, "", apperror.APIKeyNotFound() + } else if err != nil { + return ApiKey{}, "", fmt.Errorf("error loading API key: %w", err) } // Only allow renewal if the key has already expired @@ -119,7 +118,7 @@ func (s *Service) RenewApiKey(ctx context.Context, userID, apiKeyID string, expi // Generate a secure random API key token, err := utils.GenerateRandomAlphanumericString(32) if err != nil { - return ApiKey{}, "", err + return ApiKey{}, "", fmt.Errorf("error generating API key token: %w", err) } apiKey.Key = utils.CreateSha256Hash(token) @@ -127,11 +126,12 @@ func (s *Service) RenewApiKey(ctx context.Context, userID, apiKeyID string, expi err = tx.WithContext(ctx).Save(&apiKey).Error if err != nil { - return ApiKey{}, "", err + return ApiKey{}, "", fmt.Errorf("error saving API key: %w", err) } - if err := tx.Commit().Error; err != nil { - return ApiKey{}, "", err + err = tx.Commit().Error + if err != nil { + return ApiKey{}, "", fmt.Errorf("error committing transaction: %w", err) } return apiKey, token, nil @@ -144,7 +144,7 @@ func (s *Service) RevokeApiKey(ctx context.Context, userID, apiKeyID string) err Where("id = ? AND user_id = ?", apiKeyID, userID). Delete(&apiKey) if result.Error != nil { - return result.Error + return fmt.Errorf("error deleting API key: %w", result.Error) } if result.RowsAffected == 0 { return apperror.APIKeyNotFound() @@ -177,12 +177,10 @@ func (s *Service) ValidateApiKey(ctx context.Context, apiKey string) (model.User Preload("User"). First(&key). Error - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return model.User{}, apperror.InvalidAPIKey() - } - - return model.User{}, err + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.User{}, apperror.InvalidAPIKey() + } else if err != nil { + return model.User{}, fmt.Errorf("error loading API key: %w", err) } return key.User, nil @@ -199,17 +197,25 @@ func (s *Service) ListExpiringApiKeys(ctx context.Context, daysAhead int) ([]Api Where("expires_at > ? AND expires_at <= ? AND expiration_email_sent = ?", datatype.DateTime(now), datatype.DateTime(cutoff), false). Find(&keys). Error + if err != nil { + return nil, fmt.Errorf("error listing API keys: %w", err) + } - return keys, err + return keys, nil } // MarkExpirationEmailSent records that the expiration notification email was sent for the given API key func (s *Service) MarkExpirationEmailSent(ctx context.Context, apiKeyID string) error { - return s.db.WithContext(ctx). + err := s.db.WithContext(ctx). Model(&ApiKey{}). Where("id = ?", apiKeyID). Update("expiration_email_sent", true). Error + if err != nil { + return fmt.Errorf("error marking API key expiration email sent: %w", err) + } + + return nil } func (s *Service) initStaticApiKeyUser(ctx context.Context) (user model.User, err error) { @@ -223,12 +229,12 @@ func (s *Service) initStaticApiKeyUser(ctx context.Context) (user model.User, er } if !errors.Is(err, gorm.ErrRecordNotFound) { - return model.User{}, err + return model.User{}, fmt.Errorf("error loading static API key user: %w", err) } usernameSuffix, err := utils.GenerateRandomAlphanumericString(6) if err != nil { - return model.User{}, err + return model.User{}, fmt.Errorf("error generating static API key username suffix: %w", err) } user = model.User{ @@ -245,13 +251,21 @@ func (s *Service) initStaticApiKeyUser(ctx context.Context) (user model.User, er WithContext(ctx). Create(&user). Error + if err != nil { + return model.User{}, fmt.Errorf("error creating static API key user: %w", err) + } - return user, err + return user, nil } func (s *Service) deleteStaticApiKeyUser(ctx context.Context) error { - return s.db. + err := s.db. WithContext(ctx). Delete(&model.User{}, "id = ?", common.StaticApiKeyUserID). Error + if err != nil { + return fmt.Errorf("error deleting static API key user: %w", err) + } + + return nil } diff --git a/backend/internal/bootstrap/scheduler_bootstrap.go b/backend/internal/bootstrap/scheduler_bootstrap.go index 81b6bf88..aa44218f 100644 --- a/backend/internal/bootstrap/scheduler_bootstrap.go +++ b/backend/internal/bootstrap/scheduler_bootstrap.go @@ -14,10 +14,6 @@ func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, sche if err != nil { return fmt.Errorf("failed to register DB cleanup jobs in scheduler: %w", err) } - err = scheduler.RegisterApiKeyExpiryJob(ctx, svc.apiKeyModule, svc.appConfigService, svc.emailModule) - if err != nil { - return fmt.Errorf("failed to register API key expiration jobs in scheduler: %w", err) - } err = scheduler.RegisterScimJobs(ctx, svc.scimService) if err != nil { return fmt.Errorf("failed to register SCIM scheduler job: %w", err) diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index cc60d297..d1570970 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -170,8 +170,12 @@ func initServices( } svc.apiKeyModule, err = apikey.New(ctx, apikey.Dependencies{ - DB: db, - StaticApiKey: common.EnvConfig.StaticApiKey, + DB: db, + Actors: actors, + StaticApiKey: common.EnvConfig.StaticApiKey, + AppConfig: svc.appConfigService, + EmailSender: svc.emailModule, + CleanupDisabled: common.EnvConfig.AppEnv.IsTest(), }) if err != nil { return nil, fmt.Errorf("failed to create API key module: %w", err) diff --git a/backend/internal/job/api_key_expiry_job.go b/backend/internal/job/api_key_expiry_job.go deleted file mode 100644 index 42efd833..00000000 --- a/backend/internal/job/api_key_expiry_job.go +++ /dev/null @@ -1,84 +0,0 @@ -package job - -import ( - "context" - "fmt" - "log/slog" - "time" - - "github.com/go-co-op/gocron/v2" - - "github.com/pocket-id/pocket-id/backend/internal/apikey" - "github.com/pocket-id/pocket-id/backend/internal/appconfig" - "github.com/pocket-id/pocket-id/backend/internal/service" -) - -type APIKeyExpiryEmailSender interface { - SendAPIKeyExpiringSoon(ctx context.Context, dbConfig *appconfig.AppConfigModel, userFullName, userEmail, firstName, apiKeyName string, expiresAt time.Time) error -} - -type ApiKeyEmailJobs struct { - apiKeyModule *apikey.Module - appConfigService *appconfig.AppConfigService - emailSender APIKeyExpiryEmailSender -} - -func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyModule *apikey.Module, appConfigService *appconfig.AppConfigService, emailSender APIKeyExpiryEmailSender) error { - jobs := &ApiKeyEmailJobs{ - apiKeyModule: apiKeyModule, - appConfigService: appConfigService, - emailSender: emailSender, - } - - // Send every day at midnight - return s.RegisterJob(ctx, "ExpiredApiKeyEmailJob", gocron.CronJob("0 0 * * *", false), jobs.checkAndNotifyExpiringApiKeys, service.RegisterJobOpts{}) -} - -func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error { - dbConfig, err := j.appConfigService.GetConfig(ctx) - if err != nil { - return fmt.Errorf("error load app config: %w", err) - } - - // Skip if the feature is disabled - if !dbConfig.EmailApiKeyExpirationEnabled.IsTrue() { - return nil - } - - apiKeys, err := j.apiKeyModule.ListExpiringApiKeys(ctx, 7) - if err != nil { - return fmt.Errorf("failed to list expiring API keys: %w", err) - } - - for _, key := range apiKeys { - if key.User.Email == nil { - continue - } - - err = j.emailSender.SendAPIKeyExpiringSoon( - ctx, - dbConfig, - key.User.FullName(), - *key.User.Email, - key.User.FirstName, - key.Name, - key.ExpiresAt.ToTime(), - ) - if err != nil { - slog.ErrorContext(ctx, "Failed to send expiring API key notification email", - slog.String("key", key.ID), - slog.String("user", key.User.ID), - slog.Any("error", err), - ) - continue - } - - if err = j.apiKeyModule.MarkExpirationEmailSent(ctx, key.ID); err != nil { - slog.ErrorContext(ctx, "Failed to record that the expiration email was sent", - slog.String("key", key.ID), - slog.Any("error", err), - ) - } - } - return nil -} diff --git a/backend/internal/job/file_cleanup_job.go b/backend/internal/job/file_cleanup_job.go index 8e5ef6eb..743d2308 100644 --- a/backend/internal/job/file_cleanup_job.go +++ b/backend/internal/job/file_cleanup_job.go @@ -29,6 +29,7 @@ func GetFileCleanupJobs(db *gorm.DB, fileStorage storage.FileStorage) (cjs []*cr // Run every 24 hours cronjob.WithInterval(24*time.Hour), cronjob.WithLogger(slog.Default()), + cronjob.WithJitter(5*time.Minute), ) if err != nil { return nil, fmt.Errorf("error creating ClearUnusedDefaultProfilePictures job: %w", err) @@ -44,6 +45,7 @@ func GetFileCleanupJobs(db *gorm.DB, fileStorage storage.FileStorage) (cjs []*cr // Run every 12 hours cronjob.WithInterval(12*time.Hour), cronjob.WithLogger(slog.Default()), + cronjob.WithJitter(5*time.Minute), ) if err != nil { return nil, fmt.Errorf("error creating ClearOrphanedTempFiles job: %w", err) diff --git a/backend/internal/middleware/auth_middleware_test.go b/backend/internal/middleware/auth_middleware_test.go index e4369b87..16b34035 100644 --- a/backend/internal/middleware/auth_middleware_test.go +++ b/backend/internal/middleware/auth_middleware_test.go @@ -40,7 +40,7 @@ func TestWithApiKeyAuthDisabled(t *testing.T) { require.NoError(t, err) userService := service.NewUserService(db, jwtService, nil, nil, nil, nil, nil) - apiKeyModule, err := apikey.New(t.Context(), apikey.Dependencies{DB: db}) + apiKeyModule, err := apikey.New(t.Context(), apikey.Dependencies{DB: db, CleanupDisabled: true}) require.NoError(t, err) authMiddleware := NewAuthMiddleware(apiKeyModule, userService, jwtService)