mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-19 03:16:28 +00:00
refactor: move API key cleanup job to actor (#1687)
This commit is contained in:
+2
-2
@@ -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
|
||||
|
||||
+4
-8
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user