diff --git a/backend/go.mod b/backend/go.mod index bda36770..fd12ab08 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -17,6 +17,7 @@ require ( github.com/go-webauthn/webauthn v0.11.2 github.com/golang-migrate/migrate/v4 v4.18.2 github.com/google/uuid v1.6.0 + github.com/hashicorp/go-uuid v1.0.3 github.com/joho/godotenv v1.5.1 github.com/lestrrat-go/jwx/v3 v3.0.0-beta1 github.com/mileusna/useragent v1.3.5 diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index cbd3415d..ee70cf98 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -40,7 +40,7 @@ func Bootstrap() error { if err != nil { return fmt.Errorf("failed to create job scheduler: %w", err) } - err = registerScheduledJobs(ctx, db, svc, scheduler) + err = registerScheduledJobs(ctx, db, svc, httpClient, scheduler) if err != nil { return fmt.Errorf("failed to register scheduled jobs: %w", err) } @@ -48,7 +48,7 @@ func Bootstrap() error { // Init the router router := initRouter(db, svc) - // Run all background serivces + // Run all background services // This call blocks until the context is canceled err = utils. NewServiceRunner(router, scheduler.Run). diff --git a/backend/internal/bootstrap/scheduler_bootstrap.go b/backend/internal/bootstrap/scheduler_bootstrap.go index c6635486..d841e627 100644 --- a/backend/internal/bootstrap/scheduler_bootstrap.go +++ b/backend/internal/bootstrap/scheduler_bootstrap.go @@ -3,13 +3,14 @@ package bootstrap import ( "context" "fmt" + "net/http" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/job" ) -func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, scheduler *job.Scheduler) error { +func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, httpClient *http.Client, scheduler *job.Scheduler) error { err := scheduler.RegisterLdapJobs(ctx, svc.ldapService, svc.appConfigService) if err != nil { return fmt.Errorf("failed to register LDAP jobs in scheduler: %w", err) @@ -30,6 +31,10 @@ func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, sche if err != nil { return fmt.Errorf("failed to register API key expiration jobs in scheduler: %w", err) } + err = scheduler.RegisterAnalyticsJob(ctx, svc.appConfigService, httpClient) + if err != nil { + return fmt.Errorf("failed to register analytics job in scheduler: %w", err) + } return nil } diff --git a/backend/internal/common/env_config.go b/backend/internal/common/env_config.go index 9e52cbf1..00d69175 100644 --- a/backend/internal/common/env_config.go +++ b/backend/internal/common/env_config.go @@ -39,6 +39,7 @@ type EnvConfigSchema struct { MetricsEnabled bool `env:"METRICS_ENABLED"` TracingEnabled bool `env:"TRACING_ENABLED"` TrustProxy bool `env:"TRUST_PROXY"` + AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"` } var EnvConfig = &EnvConfigSchema{ @@ -57,6 +58,7 @@ var EnvConfig = &EnvConfigSchema{ MetricsEnabled: false, TracingEnabled: false, TrustProxy: false, + AnalyticsDisabled: false, } func init() { diff --git a/backend/internal/job/analytics_job.go b/backend/internal/job/analytics_job.go new file mode 100644 index 00000000..f3508cee --- /dev/null +++ b/backend/internal/job/analytics_job.go @@ -0,0 +1,61 @@ +package job + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/service" +) + +const heartbeatUrl = "https://analytics.pocket-id.org/heartbeat" + +func (s *Scheduler) RegisterAnalyticsJob(ctx context.Context, appConfig *service.AppConfigService, httpClient *http.Client) error { + jobs := &AnalyticsJob{appConfig: appConfig, httpClient: httpClient} + return s.registerJob(ctx, "SendHeartbeat", "0 0 * * *", jobs.sendHeartbeat, true) +} + +type AnalyticsJob struct { + appConfig *service.AppConfigService + httpClient *http.Client +} + +// sendHeartbeat sends a heartbeat to the analytics service +func (j *AnalyticsJob) sendHeartbeat(ctx context.Context) error { + // Skip if analytics are disabled or not in production environment + if common.EnvConfig.AnalyticsDisabled || common.EnvConfig.AppEnv != "production" { + return nil + } + + body := struct { + Version string `json:"version"` + InstanceID string `json:"instance_id"` + }{ + Version: common.Version, + InstanceID: j.appConfig.GetDbConfig().InstanceID.Value, + } + bodyBytes, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to marshal heartbeat body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, heartbeatUrl, bytes.NewBuffer(bodyBytes)) + if err != nil { + return fmt.Errorf("failed to create heartbeat request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := j.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send heartbeat request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("heartbeat request failed with status code: %d", resp.StatusCode) + } + + return nil + +} diff --git a/backend/internal/job/api_key_expiry_job.go b/backend/internal/job/api_key_expiry_job.go index c3286d53..6793cf00 100644 --- a/backend/internal/job/api_key_expiry_job.go +++ b/backend/internal/job/api_key_expiry_job.go @@ -18,7 +18,7 @@ func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyService * appConfigService: appConfigService, } - return s.registerJob(ctx, "ExpiredApiKeyEmailJob", "0 0 * * *", jobs.checkAndNotifyExpiringApiKeys) + return s.registerJob(ctx, "ExpiredApiKeyEmailJob", "0 0 * * *", jobs.checkAndNotifyExpiringApiKeys, false) } func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error { diff --git a/backend/internal/job/db_cleanup_job.go b/backend/internal/job/db_cleanup_job.go index 80e755d0..45b44044 100644 --- a/backend/internal/job/db_cleanup_job.go +++ b/backend/internal/job/db_cleanup_job.go @@ -15,11 +15,11 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro jobs := &DbCleanupJobs{db: db} return errors.Join( - s.registerJob(ctx, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions), - s.registerJob(ctx, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens), - s.registerJob(ctx, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes), - s.registerJob(ctx, "ClearOidcRefreshTokens", "0 3 * * *", jobs.clearOidcRefreshTokens), - s.registerJob(ctx, "ClearAuditLogs", "0 3 * * *", jobs.clearAuditLogs), + s.registerJob(ctx, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions, false), + s.registerJob(ctx, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens, false), + s.registerJob(ctx, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes, false), + s.registerJob(ctx, "ClearOidcRefreshTokens", "0 3 * * *", jobs.clearOidcRefreshTokens, false), + s.registerJob(ctx, "ClearAuditLogs", "0 3 * * *", jobs.clearAuditLogs, false), ) } diff --git a/backend/internal/job/file_cleanup_job.go b/backend/internal/job/file_cleanup_job.go index 3f9c85af..427f511a 100644 --- a/backend/internal/job/file_cleanup_job.go +++ b/backend/internal/job/file_cleanup_job.go @@ -17,7 +17,7 @@ import ( func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB) error { jobs := &FileCleanupJobs{db: db} - return s.registerJob(ctx, "ClearUnusedDefaultProfilePictures", "0 2 * * 0", jobs.clearUnusedDefaultProfilePictures) + return s.registerJob(ctx, "ClearUnusedDefaultProfilePictures", "0 2 * * 0", jobs.clearUnusedDefaultProfilePictures, false) } type FileCleanupJobs struct { diff --git a/backend/internal/job/geoloite_update_job.go b/backend/internal/job/geoloite_update_job.go index 0ad31f82..46001274 100644 --- a/backend/internal/job/geoloite_update_job.go +++ b/backend/internal/job/geoloite_update_job.go @@ -2,8 +2,6 @@ package job import ( "context" - "log" - "time" "github.com/pocket-id/pocket-id/backend/internal/service" ) @@ -22,22 +20,7 @@ func (s *Scheduler) RegisterGeoLiteUpdateJobs(ctx context.Context, geoLiteServic jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService} // Register the job to run every day, at 5 minutes past midnight - err := s.registerJob(ctx, "UpdateGeoLiteDB", "5 * */1 * *", jobs.updateGoeLiteDB) - if err != nil { - return err - } - - // Run the job immediately on startup, with a 1s delay - go func() { - time.Sleep(time.Second) - err = jobs.updateGoeLiteDB(ctx) - if err != nil { - // Log the error only, but don't return it - log.Printf("Failed to Update GeoLite database: %v", err) - } - }() - - return nil + return s.registerJob(ctx, "UpdateGeoLiteDB", "5 * */1 * *", jobs.updateGoeLiteDB, true) } func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error { diff --git a/backend/internal/job/ldap_job.go b/backend/internal/job/ldap_job.go index a765ab7f..95859bd1 100644 --- a/backend/internal/job/ldap_job.go +++ b/backend/internal/job/ldap_job.go @@ -2,7 +2,6 @@ package job import ( "context" - "log" "github.com/pocket-id/pocket-id/backend/internal/service" ) @@ -16,19 +15,7 @@ func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.L jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService} // Register the job to run every hour - err := s.registerJob(ctx, "SyncLdap", "0 * * * *", jobs.syncLdap) - if err != nil { - return err - } - - // Run the job immediately on startup - err = jobs.syncLdap(ctx) - if err != nil { - // Log the error only, but don't return it - log.Printf("Failed to sync LDAP: %v", err) - } - - return nil + return s.registerJob(ctx, "SyncLdap", "0 * * * *", jobs.syncLdap, true) } func (j *LdapJobs) syncLdap(ctx context.Context) error { diff --git a/backend/internal/job/scheduler.go b/backend/internal/job/scheduler.go index f30e6380..4d71f8d6 100644 --- a/backend/internal/job/scheduler.go +++ b/backend/internal/job/scheduler.go @@ -43,10 +43,8 @@ func (s *Scheduler) Run(ctx context.Context) error { return nil } -func (s *Scheduler) registerJob(ctx context.Context, name string, interval string, job func(ctx context.Context) error) error { - _, err := s.scheduler.NewJob( - gocron.CronJob(interval, false), - gocron.NewTask(job), +func (s *Scheduler) registerJob(ctx context.Context, name string, interval string, job func(ctx context.Context) error, runImmediately bool) error { + jobOptions := []gocron.JobOption{ gocron.WithContext(ctx), gocron.WithEventListeners( gocron.AfterJobRuns(func(jobID uuid.UUID, jobName string) { @@ -56,6 +54,16 @@ func (s *Scheduler) registerJob(ctx context.Context, name string, interval strin log.Printf("Job %q failed with error: %v", name, err) }), ), + } + + if runImmediately { + jobOptions = append(jobOptions, gocron.JobOption(gocron.WithStartImmediately())) + } + + _, err := s.scheduler.NewJob( + gocron.CronJob(interval, false), + gocron.NewTask(job), + jobOptions..., ) if err != nil { diff --git a/backend/internal/model/app_config.go b/backend/internal/model/app_config.go index e01d9ff5..ca525adf 100644 --- a/backend/internal/model/app_config.go +++ b/backend/internal/model/app_config.go @@ -41,6 +41,7 @@ type AppConfig struct { BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal LogoDarkImageType AppConfigVariable `key:"logoDarkImageType,internal"` // Internal + InstanceID AppConfigVariable `key:"instanceId,internal"` // Internal // Email SmtpHost AppConfigVariable `key:"smtpHost"` SmtpPort AppConfigVariable `key:"smtpPort"` diff --git a/backend/internal/model/app_config_test.go b/backend/internal/model/app_config_test.go index 4992bc3a..7290617b 100644 --- a/backend/internal/model/app_config_test.go +++ b/backend/internal/model/app_config_test.go @@ -103,7 +103,7 @@ func TestAppConfigStructMatchesUpdateDto(t *testing.T) { // Verify every AppConfig field has a matching DTO field with the same name for fieldName, keyName := range appConfigFields { - if strings.HasSuffix(fieldName, "ImageType") { + if strings.HasSuffix(fieldName, "ImageType") || keyName == "instanceId" { // Skip internal fields that shouldn't be in the DTO continue } diff --git a/backend/internal/service/app_config_service.go b/backend/internal/service/app_config_service.go index 74b0977a..71b562c6 100644 --- a/backend/internal/service/app_config_service.go +++ b/backend/internal/service/app_config_service.go @@ -13,6 +13,8 @@ import ( "sync/atomic" "time" + "github.com/hashicorp/go-uuid" + "gorm.io/gorm" "gorm.io/gorm/clause" @@ -37,6 +39,11 @@ func NewAppConfigService(initCtx context.Context, db *gorm.DB) *AppConfigService log.Fatalf("Failed to initialize app config service: %v", err) } + err = service.initInstanceID(initCtx) + if err != nil { + log.Fatalf("Failed to initialize instance ID: %v", err) + } + return service } @@ -65,6 +72,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig { BackgroundImageType: model.AppConfigVariable{Value: "jpg"}, LogoLightImageType: model.AppConfigVariable{Value: "svg"}, LogoDarkImageType: model.AppConfigVariable{Value: "svg"}, + InstanceID: model.AppConfigVariable{Value: ""}, // Email SmtpHost: model.AppConfigVariable{}, SmtpPort: model.AppConfigVariable{}, @@ -440,3 +448,23 @@ func (s *AppConfigService) loadDbConfigInternal(ctx context.Context, tx *gorm.DB return dest, nil } + +func (s *AppConfigService) initInstanceID(ctx context.Context) error { + // Check if the instance ID is already set + instanceID := s.GetDbConfig().InstanceID.Value + if instanceID != "" { + return nil + } + + newInstanceID, err := uuid.GenerateUUID() + if err != nil { + return fmt.Errorf("failed to generate new instance ID: %w", err) + } + + err = s.UpdateAppConfigValues(ctx, "instanceId", newInstanceID) + if err != nil { + return fmt.Errorf("failed to update instance ID in the database: %w", err) + } + + return nil +}