diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go index f6c80ed..6e7856f 100644 --- a/api/dependecyInjection.go +++ b/api/dependecyInjection.go @@ -36,65 +36,70 @@ import ( ) type UseCases struct { - apiKeyAuthOnce sync.Once - apiKeyAuthMiddleware func(http.Handler) http.Handler - Token *jwtauth.JWTAuth - PurgeScheduler *scheduler.Runner - ActivityCleanupScheduler *scheduler.Runner - CreateArticleUseCase *useCases.CreateArticleUseCase - GetArticleUseCase *useCases.GetArticleUseCase - ListArticlesUseCase *useCases.ListArticlesUseCase - UpdateArticleUseCase *useCases.UpdateArticleUseCase - DeleteArticleUseCase *useCases.DeleteArticleUseCase - GetUserUseCase *useCases.GetUserUseCase - CreateUserUseCase *useCases.CreateUserUseCase - AuthenticateUserUseCase *useCases.AuthenticateUserUseCase - VerifyUserUseCase *useCases.VerifyUserUseCase - CancelRegistrationUseCase *useCases.CancelRegistrationUseCase - RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase - ResetPasswordUseCase *useCases.ResetPasswordUseCase - UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase - ListUsersUseCase *useCases.ListUsersUseCase - CreateInviteUseCase *useCases.CreateInviteUseCase - CancelInviteUseCase *useCases.CancelInviteUseCase - RenewInviteUseCase *useCases.RenewInviteUseCase - ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase - GetInviteUseCase *useCases.GetInviteUseCase - AcceptInviteUseCase *useCases.AcceptInviteUseCase - SendMailUseCase *useCases.SendMailUseCase - CreateImageUseCase *useCases.CreateImageUseCase - OpenMediaUseCase *useCases.OpenMediaUseCase - ListMediaUseCase *useCases.ListMediaUseCase - RenameMediaUseCase *useCases.RenameMediaUseCase - DeleteMediaUseCase *useCases.DeleteMediaUseCase - MergeMediaUseCase *useCases.MergeMediaUseCase - GetMediaUsageUseCase *useCases.GetMediaUsageUseCase - GetPurgeSettingsUseCase *useCases.GetPurgeSettingsUseCase - UpdatePurgeSettingsUseCase *useCases.UpdatePurgeSettingsUseCase - PreviewPurgeUseCase *useCases.PreviewPurgeUseCase - RunPurgeUseCase *useCases.RunPurgeUseCase - GetSettingsUseCase *useCases.GetSettingsUseCase - GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase - CheckForUpdateUseCase *useCases.CheckForUpdateUseCase - StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase - RequestRestartUseCase *useCases.RequestRestartUseCase - RecordRunningInfoUseCase *useCases.RecordRunningInfoUseCase - CreateAPIKeyUseCase *useCases.CreateAPIKeyUseCase - ListAPIKeysUseCase *useCases.ListAPIKeysUseCase - RevokeAPIKeyUseCase *useCases.RevokeAPIKeyUseCase - AuthenticateAPIKeyUseCase *useCases.AuthenticateAPIKeyUseCase - ListActivityUseCase *useCases.ListActivityUseCase - GetActivitySummaryUseCase *useCases.GetActivitySummaryUseCase - ExportActivityUseCase *useCases.ExportActivityUseCase - GetActivityRetentionUseCase *useCases.GetActivityRetentionUseCase - UpdateActivityRetentionUseCase *useCases.UpdateActivityRetentionUseCase - ListVariantsUseCase *useCases.ListVariantsUseCase - CreateVariantUseCase *useCases.CreateVariantUseCase - UpdateVariantUseCase *useCases.UpdateVariantUseCase - SetVariantEnabledUseCase *useCases.SetVariantEnabledUseCase - DeleteVariantUseCase *useCases.DeleteVariantUseCase - PurgeVariantUseCase *useCases.PurgeVariantUseCase - ServeVariantUseCase *useCases.ServeVariantUseCase + apiKeyAuthOnce sync.Once + apiKeyAuthMiddleware func(http.Handler) http.Handler + Token *jwtauth.JWTAuth + PurgeScheduler *scheduler.Runner + ActivityCleanupScheduler *scheduler.Runner + CreateArticleUseCase *useCases.CreateArticleUseCase + GetArticleUseCase *useCases.GetArticleUseCase + ListArticlesUseCase *useCases.ListArticlesUseCase + UpdateArticleUseCase *useCases.UpdateArticleUseCase + DeleteArticleUseCase *useCases.DeleteArticleUseCase + GetUserUseCase *useCases.GetUserUseCase + CreateUserUseCase *useCases.CreateUserUseCase + AuthenticateUserUseCase *useCases.AuthenticateUserUseCase + VerifyUserUseCase *useCases.VerifyUserUseCase + CancelRegistrationUseCase *useCases.CancelRegistrationUseCase + RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase + ResetPasswordUseCase *useCases.ResetPasswordUseCase + UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase + ListUsersUseCase *useCases.ListUsersUseCase + CreateInviteUseCase *useCases.CreateInviteUseCase + CancelInviteUseCase *useCases.CancelInviteUseCase + RenewInviteUseCase *useCases.RenewInviteUseCase + ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase + GetInviteUseCase *useCases.GetInviteUseCase + AcceptInviteUseCase *useCases.AcceptInviteUseCase + SendMailUseCase *useCases.SendMailUseCase + CreateImageUseCase *useCases.CreateImageUseCase + OpenMediaUseCase *useCases.OpenMediaUseCase + ListMediaUseCase *useCases.ListMediaUseCase + RenameMediaUseCase *useCases.RenameMediaUseCase + DeleteMediaUseCase *useCases.DeleteMediaUseCase + MergeMediaUseCase *useCases.MergeMediaUseCase + GetMediaUsageUseCase *useCases.GetMediaUsageUseCase + GetPurgeSettingsUseCase *useCases.GetPurgeSettingsUseCase + UpdatePurgeSettingsUseCase *useCases.UpdatePurgeSettingsUseCase + PreviewPurgeUseCase *useCases.PreviewPurgeUseCase + RunPurgeUseCase *useCases.RunPurgeUseCase + GetSettingsUseCase *useCases.GetSettingsUseCase + GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase + CheckForUpdateUseCase *useCases.CheckForUpdateUseCase + StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase + RequestRestartUseCase *useCases.RequestRestartUseCase + RecordRunningInfoUseCase *useCases.RecordRunningInfoUseCase + CreateAPIKeyUseCase *useCases.CreateAPIKeyUseCase + ListAPIKeysUseCase *useCases.ListAPIKeysUseCase + RevokeAPIKeyUseCase *useCases.RevokeAPIKeyUseCase + AuthenticateAPIKeyUseCase *useCases.AuthenticateAPIKeyUseCase + ListActivityUseCase *useCases.ListActivityUseCase + GetActivitySummaryUseCase *useCases.GetActivitySummaryUseCase + ExportActivityUseCase *useCases.ExportActivityUseCase + GetActivityRetentionUseCase *useCases.GetActivityRetentionUseCase + UpdateActivityRetentionUseCase *useCases.UpdateActivityRetentionUseCase + ListVariantsUseCase *useCases.ListVariantsUseCase + CreateVariantUseCase *useCases.CreateVariantUseCase + UpdateVariantUseCase *useCases.UpdateVariantUseCase + SetVariantEnabledUseCase *useCases.SetVariantEnabledUseCase + DeleteVariantUseCase *useCases.DeleteVariantUseCase + PurgeVariantUseCase *useCases.PurgeVariantUseCase + ServeVariantUseCase *useCases.ServeVariantUseCase + ListImageVariantsUseCase *useCases.ListImageVariantsUseCase + GenerateImageVariantUseCase *useCases.GenerateImageVariantUseCase + GenerateAllImageVariantsUseCase *useCases.GenerateAllImageVariantsUseCase + PurgeImageVariantUseCase *useCases.PurgeImageVariantUseCase + GenerateVariantsOnUploadUseCase *useCases.GenerateVariantsOnUploadUseCase } func (c *UseCases) RunSchedulers(ctx context.Context) { @@ -285,64 +290,71 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings purgeScheduler := useCases.NewPurgeSchedulerUseCase(repos.PurgeSettingsRepo, runPurge, recorder) activityCleanup := useCases.NewCleanupActivityUseCase(repos.AuditRepo, repos.AuditSettingsRepo, recorder) + serveVariant := useCases.NewServeVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, repos.ImageRepo, imageStorage, repos.VariantRenderer, useCases.ServeVariantLimits{MaxSourceBytes: settings.ImageVariantMaxSourceBytes, MaxSourcePixels: settings.ImageVariantMaxSourcePixels, MaxConcurrentRenders: settings.ImageVariantMaxConcurrentRenders}) + return &UseCases{ - PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()), - ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()), - Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil), - CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder), - GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer), - ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer), - UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder), - DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, recorder), - GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo), - CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), - AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher, recorder), - VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher, recorder), - CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo), - RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), - ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, recorder), - UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo, recorder), - ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo), - CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), - CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo, recorder), - RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), - ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo), - GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher), - AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, recorder), - SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo), - CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage, recorder), - OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage), - ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo), - RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder), - DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder), - MergeMediaUseCase: mergeMedia, - GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo), - GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo), - UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now), - PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo), - RunPurgeUseCase: runPurge, - GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version), - GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, settings.Container, time.Now), - CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(repos.ReleaseFeed, repos.UpdateStore, settings.Version, time.Now, recorder), - StartUpdateDownloadUseCase: startUpdateDownloadUseCase(repos, settings, recorder), - RequestRestartUseCase: useCases.NewRequestRestartUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, time.Now, recorder), - RecordRunningInfoUseCase: useCases.NewRecordRunningInfoUseCase(repos.RunningInfo, settings.Version, os.Getpid(), time.Now), - CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now, recorder), - ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now), - RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now, recorder), - AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, repos.UserRepo, time.Now), - ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now), - GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(repos.AuditRepo, time.Now), - ExportActivityUseCase: useCases.NewExportActivityUseCase(repos.AuditRepo, time.Now), - GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, time.Now), - UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, recorder, time.Now), - ListVariantsUseCase: useCases.NewListVariantsUseCase(repos.VariantRepo, repos.VariantFileRepo), - CreateVariantUseCase: useCases.NewCreateVariantUseCase(repos.VariantRepo, repos.VariantRenderer, time.Now, recorder), - UpdateVariantUseCase: useCases.NewUpdateVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, repos.VariantRenderer, time.Now, recorder), - SetVariantEnabledUseCase: useCases.NewSetVariantEnabledUseCase(repos.VariantRepo, repos.VariantFileRepo, time.Now, recorder), - DeleteVariantUseCase: useCases.NewDeleteVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, recorder), - PurgeVariantUseCase: useCases.NewPurgeVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, recorder), - ServeVariantUseCase: useCases.NewServeVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, repos.ImageRepo, imageStorage, repos.VariantRenderer, useCases.ServeVariantLimits{MaxSourceBytes: settings.ImageVariantMaxSourceBytes, MaxSourcePixels: settings.ImageVariantMaxSourcePixels, MaxConcurrentRenders: settings.ImageVariantMaxConcurrentRenders}), + PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()), + ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()), + Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil), + CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder), + GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer), + ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer), + UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder), + DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, recorder), + GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo), + CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), + AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher, recorder), + VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher, recorder), + CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo), + RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), + ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, recorder), + UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo, recorder), + ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo), + CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), + CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo, recorder), + RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), + ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo), + GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher), + AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, recorder), + SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo), + CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage, recorder), + OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage), + ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo, repos.VariantFileRepo), + RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder), + DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder), + MergeMediaUseCase: mergeMedia, + GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo), + GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo), + UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now), + PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo), + RunPurgeUseCase: runPurge, + GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version), + GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, settings.Container, time.Now), + CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(repos.ReleaseFeed, repos.UpdateStore, settings.Version, time.Now, recorder), + StartUpdateDownloadUseCase: startUpdateDownloadUseCase(repos, settings, recorder), + RequestRestartUseCase: useCases.NewRequestRestartUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, time.Now, recorder), + RecordRunningInfoUseCase: useCases.NewRecordRunningInfoUseCase(repos.RunningInfo, settings.Version, os.Getpid(), time.Now), + CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now, recorder), + ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now), + RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now, recorder), + AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, repos.UserRepo, time.Now), + ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now), + GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(repos.AuditRepo, time.Now), + ExportActivityUseCase: useCases.NewExportActivityUseCase(repos.AuditRepo, time.Now), + GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, time.Now), + UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, recorder, time.Now), + ListVariantsUseCase: useCases.NewListVariantsUseCase(repos.VariantRepo, repos.VariantFileRepo), + CreateVariantUseCase: useCases.NewCreateVariantUseCase(repos.VariantRepo, repos.VariantRenderer, time.Now, recorder), + UpdateVariantUseCase: useCases.NewUpdateVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, repos.VariantRenderer, time.Now, recorder), + SetVariantEnabledUseCase: useCases.NewSetVariantEnabledUseCase(repos.VariantRepo, repos.VariantFileRepo, time.Now, recorder), + DeleteVariantUseCase: useCases.NewDeleteVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, recorder), + PurgeVariantUseCase: useCases.NewPurgeVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, recorder), + ServeVariantUseCase: serveVariant, + ListImageVariantsUseCase: useCases.NewListImageVariantsUseCase(repos.ImageRepo, repos.VariantRepo, repos.VariantFileRepo, repos.VariantRenderer), + GenerateImageVariantUseCase: useCases.NewGenerateImageVariantUseCase(repos.ImageRepo, repos.VariantRepo, repos.VariantFileRepo, repos.VariantRenderer, serveVariant), + GenerateAllImageVariantsUseCase: useCases.NewGenerateAllImageVariantsUseCase(repos.ImageRepo, repos.VariantRepo, repos.VariantFileRepo, repos.VariantRenderer, serveVariant, recorder), + PurgeImageVariantUseCase: useCases.NewPurgeImageVariantUseCase(repos.ImageRepo, repos.VariantRepo, repos.VariantFileRepo, imageStorage, recorder), + GenerateVariantsOnUploadUseCase: useCases.NewGenerateVariantsOnUploadUseCase(repos.VariantRepo, serveVariant, log.Default()), } } diff --git a/api/handlers/media/mediaHandler.go b/api/handlers/media/mediaHandler.go index f3e5a0d..93f4a28 100644 --- a/api/handlers/media/mediaHandler.go +++ b/api/handlers/media/mediaHandler.go @@ -29,6 +29,11 @@ type Handler struct { UpdatePurgeSettingsUseCase *useCases.UpdatePurgeSettingsUseCase PreviewPurgeUseCase *useCases.PreviewPurgeUseCase RunPurgeUseCase *useCases.RunPurgeUseCase + ListVariantsUseCase *useCases.ListImageVariantsUseCase + GenerateVariantUseCase *useCases.GenerateImageVariantUseCase + GenerateAllUseCase *useCases.GenerateAllImageVariantsUseCase + PurgeVariantUseCase *useCases.PurgeImageVariantUseCase + GenerateOnUploadUseCase *useCases.GenerateVariantsOnUploadUseCase } type RenameRequest struct { @@ -53,6 +58,9 @@ type mediaItemResponse struct { CreatedAt time.Time `json:"created_at"` UsedIn int `json:"used_in"` Duplicate bool `json:"duplicate"` + + VariantCount int `json:"variant_count"` + VariantSize int64 `json:"variant_size"` } type countsResponse struct { @@ -78,7 +86,7 @@ type mergeResponse struct { Merged int `json:"merged"` } -func NewHandler(create *useCases.CreateImageUseCase, list *useCases.ListMediaUseCase, rename *useCases.RenameMediaUseCase, deleteUseCase *useCases.DeleteMediaUseCase, merge *useCases.MergeMediaUseCase, usage *useCases.GetMediaUsageUseCase, purgeSettings *useCases.GetPurgeSettingsUseCase, updatePurgeSettings *useCases.UpdatePurgeSettingsUseCase, previewPurge *useCases.PreviewPurgeUseCase, runPurge *useCases.RunPurgeUseCase) *Handler { +func NewHandler(create *useCases.CreateImageUseCase, list *useCases.ListMediaUseCase, rename *useCases.RenameMediaUseCase, deleteUseCase *useCases.DeleteMediaUseCase, merge *useCases.MergeMediaUseCase, usage *useCases.GetMediaUsageUseCase, purgeSettings *useCases.GetPurgeSettingsUseCase, updatePurgeSettings *useCases.UpdatePurgeSettingsUseCase, previewPurge *useCases.PreviewPurgeUseCase, runPurge *useCases.RunPurgeUseCase, listVariants *useCases.ListImageVariantsUseCase, generateVariant *useCases.GenerateImageVariantUseCase, generateAll *useCases.GenerateAllImageVariantsUseCase, purgeVariant *useCases.PurgeImageVariantUseCase, generateOnUpload *useCases.GenerateVariantsOnUploadUseCase) *Handler { return &Handler{ CreateUseCase: create, ListUseCase: list, @@ -90,6 +98,11 @@ func NewHandler(create *useCases.CreateImageUseCase, list *useCases.ListMediaUse UpdatePurgeSettingsUseCase: updatePurgeSettings, PreviewPurgeUseCase: previewPurge, RunPurgeUseCase: runPurge, + ListVariantsUseCase: listVariants, + GenerateVariantUseCase: generateVariant, + GenerateAllUseCase: generateAll, + PurgeVariantUseCase: purgeVariant, + GenerateOnUploadUseCase: generateOnUpload, } } @@ -145,6 +158,9 @@ func toResponse(item useCases.MediaItem) mediaItemResponse { CreatedAt: item.Image.CreatedAt, UsedIn: item.UsedIn, Duplicate: item.Duplicate, + + VariantCount: item.VariantCount, + VariantSize: item.VariantSize, } } @@ -161,6 +177,12 @@ func parseListQuery(r *http.Request) (domainImage.ListQuery, error) { if values.Has("filter") { query.Filter = domainImage.Filter(values.Get("filter")) } + if values.Has("variants") { + if values.Get("variants") == "" { + return domainImage.ListQuery{}, domainImage.ErrInvalidQuery + } + query.Variants = domainImage.VariantsFilter(values.Get("variants")) + } if values.Has("sort") { query.Sort = domainImage.Sort(values.Get("sort")) } @@ -219,6 +241,7 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) { return } + h.GenerateOnUploadUseCase.Generate(r.Context(), created) h.writeItem(w, http.StatusCreated, created) } diff --git a/api/handlers/media/variantsHandler.go b/api/handlers/media/variantsHandler.go new file mode 100644 index 0000000..64e219b --- /dev/null +++ b/api/handlers/media/variantsHandler.go @@ -0,0 +1,161 @@ +package media + +import ( + "RenewCMS/api/pkg/apperr" + domainImage "RenewCMS/internal/domain/image" + "RenewCMS/internal/infrastructure/useCases" + "errors" + "net/http" + + "github.com/go-chi/chi/v5" +) + +type imageVariantResponse struct { + Name string `json:"name"` + Width *int `json:"width"` + Height *int `json:"height"` + Fit domainImage.VariantFit `json:"fit"` + Format domainImage.VariantFormat `json:"format"` + OutputWidth int `json:"output_width"` + OutputHeight int `json:"output_height"` + OutputFormat domainImage.VariantFormat `json:"output_format"` + Generated bool `json:"generated"` + Size *int64 `json:"size"` + URL string `json:"url"` +} + +type imageVariantListResponse struct { + Unsupported *string `json:"unsupported"` + Items []imageVariantResponse `json:"items"` +} + +type variantFailureResponse struct { + Name string `json:"name"` + Cause string `json:"cause"` +} + +type generateAllResponse struct { + Generated int `json:"generated"` + Failed int `json:"failed"` + Failures []variantFailureResponse `json:"failures"` + Items []imageVariantResponse `json:"items"` +} + +type variantPurgeResponse struct { + PurgedFiles int `json:"purged_files"` + PurgedBytes int64 `json:"purged_bytes"` +} + +func toImageVariantResponse(view useCases.ImageVariantView) imageVariantResponse { + response := imageVariantResponse{ + Name: view.Variant.Name, + Width: view.Variant.Width, + Height: view.Variant.Height, + Fit: view.Variant.Fit, + Format: view.Variant.Format, + OutputWidth: view.OutputWidth, + OutputHeight: view.OutputHeight, + OutputFormat: view.OutputFormat, + Generated: view.Generated, + URL: view.URL, + } + if view.Generated { + size := view.Size + response.Size = &size + } + return response +} + +func toImageVariantResponses(views []useCases.ImageVariantView) []imageVariantResponse { + items := make([]imageVariantResponse, 0, len(views)) + for _, view := range views { + items = append(items, toImageVariantResponse(view)) + } + return items +} + +func writeImageVariantError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, domainImage.ErrVariantNotFound): + http.Error(w, apperr.VariantNotFoundErrorMessage, http.StatusNotFound) + case errors.Is(err, domainImage.ErrVariantSourceUnsupported): + http.Error(w, apperr.VariantSourceUnsupportedErrorMessage, http.StatusUnprocessableEntity) + case errors.Is(err, domainImage.ErrSourceTooLarge): + http.Error(w, apperr.VariantSourceTooLargeErrorMessage, http.StatusRequestEntityTooLarge) + case errors.Is(err, domainImage.ErrVariantSourceUnreadable): + http.Error(w, apperr.VariantSourceUnreadableErrorMessage, http.StatusUnsupportedMediaType) + default: + writeImageError(w, err) + } +} + +func (h *Handler) ListVariants(w http.ResponseWriter, r *http.Request) { + id, ok := parseImageId(w, r) + if !ok { + return + } + + list, err := h.ListVariantsUseCase.List(id) + if err != nil { + writeImageVariantError(w, err) + return + } + + response := imageVariantListResponse{Items: toImageVariantResponses(list.Items)} + if list.Unsupported != "" { + response.Unsupported = &list.Unsupported + } + writeJSON(w, http.StatusOK, response) +} + +func (h *Handler) GenerateVariant(w http.ResponseWriter, r *http.Request) { + id, ok := parseImageId(w, r) + if !ok { + return + } + + view, err := h.GenerateVariantUseCase.Generate(r.Context(), id, chi.URLParam(r, "name")) + if err != nil { + writeImageVariantError(w, err) + return + } + writeJSON(w, http.StatusOK, toImageVariantResponse(view)) +} + +func (h *Handler) GenerateAllVariants(w http.ResponseWriter, r *http.Request) { + id, ok := parseImageId(w, r) + if !ok { + return + } + + result, err := h.GenerateAllUseCase.Generate(r.Context(), id) + if err != nil { + writeImageVariantError(w, err) + return + } + + failures := make([]variantFailureResponse, 0, len(result.Failures)) + for _, failure := range result.Failures { + failures = append(failures, variantFailureResponse{Name: failure.Name, Cause: failure.Cause}) + } + writeJSON(w, http.StatusOK, generateAllResponse{ + Generated: result.Generated, + Failed: result.Failed, + Failures: failures, + Items: toImageVariantResponses(result.Items), + }) +} + +func (h *Handler) PurgeVariant(w http.ResponseWriter, r *http.Request) { + id, ok := parseImageId(w, r) + if !ok { + return + } + + purged, err := h.PurgeVariantUseCase.Purge(r.Context(), id, chi.URLParam(r, "name")) + if err != nil { + writeImageVariantError(w, err) + return + } + writeJSON(w, http.StatusOK, variantPurgeResponse{PurgedFiles: purged.Files, PurgedBytes: purged.Bytes}) +} diff --git a/api/middleware/apiKey.go b/api/middleware/apiKey.go index 0ccf9a0..e2c222a 100644 --- a/api/middleware/apiKey.go +++ b/api/middleware/apiKey.go @@ -51,6 +51,16 @@ func RequireSession(next http.Handler) http.Handler { }) } +func RejectAPIKey(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, authenticatedByKey := apiKeyIdFrom(r); authenticatedByKey { + http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + func APIKeyAuth(authenticate *useCases.AuthenticateAPIKeyUseCase, maxRequests int, window time.Duration) func(http.Handler) http.Handler { limiter := newKeyedRateLimiter(maxRequests, window, apiKeyRateLimitKey) diff --git a/api/pkg/apperr/messages.go b/api/pkg/apperr/messages.go index 6f0414e..5f3612f 100644 --- a/api/pkg/apperr/messages.go +++ b/api/pkg/apperr/messages.go @@ -51,5 +51,7 @@ const VariantInvalidErrorMessage = "The variant is not valid." const VariantExistsErrorMessageFormat = "A variant named “%s” already exists. Choose another name." const VariantFormatErrorMessageFormat = "Format must be %s." const VariantSourceTooLargeErrorMessage = "The source image is too large to generate this variant." +const VariantSourceUnsupportedErrorMessage = "Variants aren't generated for this format." +const VariantSourceUnreadableErrorMessage = "The source image can't be read, so this variant can't be generated." const VariantUnknownMessage = "Unknown variant" const VariantUnsupportedParameterMessage = "Unsupported parameter" diff --git a/api/router.go b/api/router.go index cab290c..ecab90f 100644 --- a/api/router.go +++ b/api/router.go @@ -250,11 +250,12 @@ func NewAPIKeyRouter(container *UseCases, trustedProxies []string) http.Handler func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler { r := chi.NewRouter() - mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase, container.GetPurgeSettingsUseCase, container.UpdatePurgeSettingsUseCase, container.PreviewPurgeUseCase, container.RunPurgeUseCase) + mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase, container.GetPurgeSettingsUseCase, container.UpdatePurgeSettingsUseCase, container.PreviewPurgeUseCase, container.RunPurgeUseCase, container.ListImageVariantsUseCase, container.GenerateImageVariantUseCase, container.GenerateAllImageVariantsUseCase, container.PurgeImageVariantUseCase, container.GenerateVariantsOnUploadUseCase) r.Use(jwtauth.Verifier(container.Token)) r.Use(container.apiKeyAuth()) r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies)) + r.Use(middleware.RequireSignedIn) r.Use(jwtauth.Authenticator(container.Token)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) @@ -265,6 +266,14 @@ func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler { r.Get("/purge-preview", mediaHandler.PreviewPurge) r.Post("/purge", mediaHandler.RunPurge) }) + r.Group(func(r chi.Router) { + r.Use(middleware.RejectAPIKey) + r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase)) + r.Get("/{id}/variants", mediaHandler.ListVariants) + r.Post("/{id}/variants", mediaHandler.GenerateAllVariants) + r.Post("/{id}/variants/{name}", mediaHandler.GenerateVariant) + r.Delete("/{id}/variants/{name}", mediaHandler.PurgeVariant) + }) r.Get("/", mediaHandler.List) r.Post("/", mediaHandler.Upload) r.Post("/merge", mediaHandler.Merge) diff --git a/docs/api.md b/docs/api.md index d587f55..c72c873 100644 --- a/docs/api.md +++ b/docs/api.md @@ -23,7 +23,7 @@ for example `https://cms.example/v1/article/`. | Group | Routes | | --- | --- | | Articles | GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} | -| Media | GET /v1/media/, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET /v1/media/purge-settings (owner or admin), PATCH /v1/media/purge-settings (owner or admin), GET /v1/media/purge-preview (owner or admin), POST /v1/media/purge (owner or admin) | +| Media | GET /v1/media/, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET /v1/media/purge-settings (owner or admin), PATCH /v1/media/purge-settings (owner or admin), GET /v1/media/purge-preview (owner or admin), POST /v1/media/purge (owner or admin), GET /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants/{name} (owner or admin, session only), DELETE /v1/media/{id}/variants/{name} (owner or admin, session only) | | Users and invites | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite (owner only), DELETE /v1/user/invite/{id} (owner only), POST /v1/user/invite/{id}/renew (owner only), GET /v1/auth/me | | Settings | GET /v1/settings | @@ -62,7 +62,7 @@ never changes the response or the change itself. | Category | Actions | | --- | --- | | `articles` | `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete` | -| `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge`, `media.variant_create`, `media.variant_update`, `media.variant_delete`, `media.variant_enable`, `media.variant_disable`, `media.variant_purge` | +| `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge`, `media.variant_create`, `media.variant_update`, `media.variant_delete`, `media.variant_enable`, `media.variant_disable`, `media.variant_purge`, `media.variant_generate_all`, `media.variant_purge_image` | | `users` | `user.register`, `user.verify`, `user.update_profile`, `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` | | `system` | `system.media_purge` (scheduled), `system.update_checked`, `system.update_downloaded`, `system.restart_requested`, `system.activity_cleanup`, `system.activity_retention_changed` | @@ -73,6 +73,10 @@ kinds `text` and `body`; a body edit carries a signed character count delta and change that leaves a value as it was (same body, same publish state, same file name, same display name) records nothing. Params: `media.merge` and `media.purge` and `system.media_purge` carry `merged` and `articles_updated`, plus `failed_groups` when a group failed (still outcome `success`); +`media.variant_generate_all` carries `generated`, `failed` and `causes` (a list of `too_large`, +`unsupported_format` or `failed`) and is `failed` only when nothing was generated and something failed; +`media.variant_purge_image` carries `purged_files` and `purged_bytes`; both have an `image` target +and are recorded only when files were generated or removed; `system.update_checked` carries `update_available`; `system.restart_requested` carries `kind` and, for an update, `tag` (it has no target). `system.update_downloaded` is recorded when the job reaches `ready` or `failed`, with the origin captured at request time; a refusal at the start is recorded @@ -88,7 +92,10 @@ try again."; the text of the error never reaches the event, the API or the CSV. codes, article bodies, file bytes, storage paths and asset urls are never recorded. Article routes accept `?format=markdown|html|json` (default `markdown`). `GET /v1/media/` accepts -`q`, `hash`, `cursor`, `filter`, `sort` and `limit`. +`q`, `hash`, `cursor`, `filter`, `sort` (`newest`, `oldest`, `name`, `largest` or `total_size`, the +original plus its generated variants), `variants` (`with` or `without` generated variant files) and +`limit`. Every item carries `variant_count` and `variant_size` (the index rows of the image and +their bytes, 0 when none); `counts` stays library-wide. Any other `variants` or `sort` value is 400. ## Image variants Owners and admins manage named variants of the stored images. These routes are session only (no API @@ -115,6 +122,30 @@ Errors are plain text: 400 for a body that is not valid JSON, 404 "Variant not f named “thumb” already exists. Choose another name." and 422 with one sentence for the first invalid field (name, width and height, fit, format, quality). +### Variants of one image +Owners and admins only, session only: a request with an API key is 401. All ids are image ids. + +| Route | Purpose | +| --- | --- | +| `GET /v1/media/{id}/variants` | `{ "unsupported": null, "items": [...] }` for the enabled variants in name order. `unsupported` is `"svg"` (with `items: []`) for an SVG. | +| `POST /v1/media/{id}/variants` | Generates every enabled variant that is missing. `{ "generated", "failed", "failures": [{ "name", "cause" }], "items": [...] }`, 200 even when some fail. | +| `POST /v1/media/{id}/variants/{name}` | Generates one enabled variant. 200 with the item. | +| `DELETE /v1/media/{id}/variants/{name}` | Removes the generated file. `{ "purged_files", "purged_bytes" }`, also for a disabled variant. | + +An item is `{ name, width, height, fit, format, output_width, output_height, output_format, +generated, size, url }`: `width` and `height` are the settings (`null` when auto), `output_*` the +size and format the file has or will have (never above the source), `generated` is true only when +the file exists with the variant's current settings, `size` is its bytes or `null`, and `url` is +`/static/uploadedImages/?v=`. A `cause` is `too_large`, `unsupported_format` or +`failed`. A file generated here is the same file the public url serves, and the other way round. +Uploads generate the variants flagged `generate_on_upload` (enabled ones, raster sources only) +before answering; a failure there is logged and never fails the upload. + +Errors are plain text: 400 for a malformed id, 404 "Image not found" or "Variant not found" (also for a +disabled variant on `POST`), 413 "The source image is too large to generate this variant.", 415 "The +source image can't be read, so this variant can't be generated.", 422 "Variants aren't generated for +this format." and 500 "Internal server error". + ## Errors Every error is a plain-text body with one sentence; the status is in the HTTP status line. The statuses are 400, 401, 403, 404, 409, 429 and 500. Messages are listed on the Integration page. diff --git a/features/image_variants_editor_picker.feature b/features/image_variants_editor_picker.feature new file mode 100644 index 0000000..8694ac9 --- /dev/null +++ b/features/image_variants_editor_picker.feature @@ -0,0 +1,211 @@ +Feature: The editor's Variant select and the warning on blocks whose variant is gone + + As an owner or admin writing an article + I want to choose a variant when I insert an image from the library, and be warned when a variant used by an image in my article was deleted or disabled + So that the article asks the right size and I notice before readers get an error + + Background: + Given the spec of record is docs/design/screens/image-variants.md section 6 and its states 6a to 6e, docs/design/screens/media-picker.md, the board docs/design/source/RenewCMS Screens Image variants.dc.html drawn by VariantsFrame.dc.html + And this slice is SPA only: it uses GET /v1/image-variants (owner/admin, features/image-variants.feature) and the media library API unchanged, and builds on features/editor_media_picker.feature, which stays as it is except where this file says so + And the Settings screens and the media page are not in this slice + And the Variant select is a USelectMenu in the Library tab's selection panel, under the thumbnail row, before the "Alt text (optional)" field + And the list of variants is requested with GET /v1/image-variants once per opening of the dialog, when the Library tab first shows an image selection, and only enabled variants are offered + And a variant option reads " · · " where the name is capitalised, the dimensions read "400×300", "800 × auto" or "auto × 2400" as in the Settings list, and the format reads WebP, JPEG, PNG or Original; the first option reads "Original · × · " from the selected image + And the options are Original first, then the enabled variants in the order the API returns them (name ascending) + And the url line under the select shows what will be inserted, in mono 12 px muted: "/static/uploadedImages/" for Original and "/static/uploadedImages/?v=" for a variant (the design's "/media/" is replaced per the design's own implementation notes) + And the select label reads "Variant" (medium weight), the control is 40 px high on desktop and 44 px high with 16 px text on mobile, and its open list has 36 px options on desktop and 44 px on mobile with a check on the selected one + And the fixture is the library of features/editor_media_picker.feature (north-wall.jpg id 1 stored "1.jpg", 4032 x 3024 JPEG, 3.9 MB) and the variants thumb (cover 400 x 300 webp), card (inside 800 x auto webp), hero (cover 1200 x 630 webp), og (cover 1200 x 630 jpeg), square (cover 600 x 600 jpeg), all enabled, in name order card, hero, og, square, thumb, and "print" disabled + And every new English string has a French entry in web/src/i18n/messages.ts, and no comment is added to any file + + Rule: The Variant select + + Scenario: Selecting an image shows the select with Original chosen + Given the Library tab shows the fixture + When the user selects "north-wall.jpg" + Then GET /v1/image-variants is sent once + And the panel shows the thumbnail row, then the label "Variant" over a select reading "Original · 4032×3024 · JPEG", then the url line "/static/uploadedImages/1.jpg", then the alt text field + And "Insert image" is enabled + + Scenario: The open list offers Original and the enabled variants + When the user opens the select + Then the options read, in order: + | Original · 4032×3024 · JPEG | + | Card · 800 × auto · WebP | + | Hero · 1200×630 · WebP | + | Og · 1200×630 · JPEG | + | Square · 600×600 · JPEG | + | Thumb · 400×300 · WebP | + And "Print" is not offered + And the select border is primary with a 1 px primary ring while it is open + + Scenario: Choosing a variant changes the url line + When the user chooses "Hero · 1200×630 · WebP" + Then the select reads that option and the url line reads "/static/uploadedImages/1.jpg?v=hero" + + Scenario: The choice is reset when another image is selected + Given "north-wall.jpg" is selected with the variant "Hero" + When the user selects "kitchen-after.webp" + Then the select reads "Original · 2400×1600 · WEBP" and the url line has no "?v=" + + Scenario: The variants list is not requested again within one opening + Given the user selected one image and then another + Then GET /v1/image-variants was sent once + + Scenario: No variants defined + Given GET /v1/image-variants resolves {"items": []} + When the user selects "north-wall.jpg" + Then the select offers only "Original · 4032×3024 · JPEG" + And the help line reads "No variants defined. Add one in Settings → Media → Variants." with an info icon, and "Settings → Media → Variants" links to /settings/media/variants + And the url line is not shown + + Scenario: Only disabled variants count as none + Given every variant is disabled + Then the select offers only Original and the "No variants defined" help shows + + Scenario: An SVG offers only Original + When the user selects "logo-renew.svg" + Then the select offers only "Original · Vector · SVG" and no help line shows + + Scenario Outline: The variants list fails to load + Given GET /v1/image-variants + When the user selects "north-wall.jpg" + Then the select offers only Original, no error alert shows, and "Insert image" is enabled + + Examples: + | failure | + | resolves with a 500 response | + | resolves with a 403 response | + | never reaches the server | + + Scenario: Keyboard + Given the select has focus + Then Enter or Space opens it, the arrow keys move between options, Enter chooses, Escape closes it and returns focus to the select + + Rule: Inserting a variant + + Scenario Outline: The inserted url carries the variant + Given "north-wall.jpg" is selected with the variant "